Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): compare OCC version tokens as instants, not spellings (#13382) - #13569
Conversation
…pellings (#13382) On Postgres — the production default driver — every guarded save answered 409 CONCURRENT_UPDATE, including on records nobody had ever touched. The OCC gate read `updated_at` through `String(v)`; on a Date-returning driver that is `Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)` — milliseconds dropped, process timezone baked in — compared as a string against the `2026-08-30T10:19:25.947Z` the client echoed back from its own GET. One instant, two spellings. SQLite returns canonical ISO text, so both sides matched by accident and development environments stayed green. Both tokens are now normalised to one representation — a canonical absolute instant — before comparison, and the `currentVersion` a 409 publishes is that same canonical instant: what the wire format documents, and the token the conflict dialog echoes back as its next If-Match. Strictly widening: when either side is not an instant the verbatim comparison still runs, so no token accepted before is refused now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
`check:engine-double-contract` retains a pin per (file, verb). The regression suite added for #13382 carries a fake engine whose delete/update/findOne route through the producer's own dispatch predicates, so the ledger has to learn about it or it never protects the file. `--write`, 3 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ff4d0f267fef8baddf98020fc59a8f15f2ec5c52 && git checkout ff4d0f267fef8baddf98020fc59a8f15f2ec5c52
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c120f0308d5aed3c26f53c406054a95a631d156 14c197f87ed384e24757413a2362c7687d1e94ac && git checkout -B drift-repro 9c120f0308d5aed3c26f53c406054a95a631d156 && git merge --no-ff 14c197f87ed384e24757413a2362c7687d1e94ac
node scripts/docs-audit/affected-docs.mjs --json 9c120f0308d5aed3c26f53c406054a95a631d156
|
… line (#13382) `content/docs/permissions/system-context.mdx` anchors elevation reads by line number. The OCC fix added 158 lines above `stripReadonlyForInsert`, so row 21's anchor into metadata-protocol rotted: the census found an unanchored read at :1566 and an anchor at :1451 that is no longer a read site. A PURE SHIFT, verified before repairing rather than assumed — the gate refuses to guess a population change and rewrites only line rot: base :1451 and head :1566 are the byte-identical `if (context?.isSystem) return data;` (sha256 db885f75…), the `isSystem` occurrence count in the file is 9 on both trees, and :1566 is the first line of `stripReadonlyForInsert`, which is exactly what row 21 describes. Repaired with the gate's own `--fix`; one anchor rewritten, no prose touched, nothing baselined or exempted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
zhuangjianguo
commented
Aug 31, 2026
Contract review (条款②, CONTRACT_REVIEW_TIER) — verdict: FINDINGS (1 concrete, accept-side)Reviewed at head Finding 1 — the strict-widening claim has a counterexample: the RFC-7232 quoted-empty token |
…of OCC (#13382) Contract review caught a regression this PR introduced. `If-Match: ""` is empty only AFTER the RFC-7232 quotes come off, and the pre-fix seam returned the bare string, so that case handed every caller the falsy `''` and they short-circuited into "no token supplied". Wrapping the result in an object made it always truthy, so the check began running and `''` mismatched verbatim: an accept-to-refuse flip on a shipped API, falsifying this PR's own strictly- widening claim. Remedy is the conservative one — re-apply the emptiness test after the strip, so the behaviour is byte-identical to `70fe54891e`. Whether an empty entity-tag SHOULD be able to disable OCC is a contract question, filed separately; a p1 bug-fix PR does not silently install a new rejection. The claim is no longer left as prose. A new block sweeps a corpus of 8 stored shapes x 15 client tokens against the pre-fix comparison reproduced verbatim and fails on any pair that was accepted before and is refused now. Run against the unfixed tree it reports exactly the 5 `""` pairs and nothing else, so the review finding was the whole regression rather than one instance of a class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
zhuangjianguo
commented
Aug 31, 2026
PM review — ACCEPT. Clause ② satisfied, CI fully green, releasing.
The clause ② finding, and why it is closedThe contract review returned FINDINGS: The remedy landed exactly as prescribed: The reviewer had pre-blessed this remedy and found nothing else at tier, so clause ② is satisfied without a re-review. The catch that saved a second redThe ⛔ A correction to my own dispatch orderI told the seat the failing gate lives "inside The one addition beyond the brief — accepted, and it earned itThe differential sweep (8 stored shapes × 15 client tokens, judged against the pre-fix comparison reproduced verbatim) was flagged rather than slipped in, and the argument for it is the right one: the strictly-widening claim was prose in four places and read as true by three separate readers while one token had already flipped. Prose is what failed here, so the claim now has an executable check. It also produced a result nothing else could: run against the unfixed tree it reports exactly the five empty-entity-tag pairs and nothing else, which establishes the review's finding was the whole regression rather than one instance of a class. Test-only, same file, same package, same gate family — not a widening of the PR, but a pin on the PR's own central claim. It stays. Verified independently
Not filed, deliberatelyThe PR body's attribution footer is gone after the edit. I can see that, but I cannot distinguish "the channel stripped it" from "the PATCH payload omitted it" — and those imply opposite fixes. The mechanism is observable; the binding is not established, so it stays an observation rather than a card asserting a cause. ReleaseAll 36 checks complete: 35 success, 1 skipped, 0 red. Flipping ready and arming auto-merge; the merge queue does the merging. ⛔ Never a hand-merge, never a queue bypass. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13382
On Postgres — the production default driver — every guarded save answered
409 CONCURRENT_UPDATE, including on records nobody had ever touched, which made the Console record-edit dialog unusable. SQLite development environments were completely unaffected, which is why it survived.Reproduced, then fixed, against a real PostgreSQL 16
A PostgreSQL 16 server was provisioned in this container and driven end to end through the real
ObjectQLengine andObjectStackProtocolImplementation. This is not a SQLite pass presented as verification — SQLite is shown beside it precisely as the control that stays green either way.Before, with the card's own
TZ=Asia/Shanghai:After, the same harness under three process timezones —
Asia/Shanghai, CI'sAmerica/New_York, andUTC— reportsACCEPTEDon both drivers in all three, and the seven controls below hold on Postgres and on SQLite alike:If-Match)CONCURRENT_UPDATEcurrentVersionechoed straight backString(updated_at)tokenCONCURRENT_UPDATECONCURRENT_UPDATEWhere it actually is
packages/metadata-protocol/src/protocol.ts— notpackages/rest/packages/driver-sql, which the card's prose names. Located on70fe54891e, since the two comments on the card disagree and both were measured on a moving file:The definition is at
:1378as triage said; the call sites had moved to:10012/:10015, and there is a fourth site —assertVersionMatch, the guarded-DELETE door, which the anchoring note did not name.The input domain, enumerated from what the drivers actually return
Measured through
engine.findOne— the readprobeRecordmakes — not listed from memory:updated_atDatedriver-sqlon Postgres (measured, live) and MySQL (withUtcSessionsets mysql2timezone: 'Z'; the driver keepstimestamptz/DATETIME(3)asDatedeliberately —withPostgresCalendarDayAsTextsays so);driver-mongodb(stampsnew Date(), BSON round-trips it)toISOString()string, canonical ISO-8601 UTC with msdriver-memory(measured),driver-sqlon SQLite (measured),driver-turso,driver-sqlite-wasmDatenumber, epoch millisecondsField.datetimecolumns, anddatetimeFields[table]is empty for the engine-injected audit columns, so an INTEGERupdated_atpasses through unrepaired and arrives here as1756602000123new Date(ms).toISOString()null/undefinedNormalisation lands on the absolute instant and reads no process timezone: only a string carrying an explicit
Zor a numeric offset is parsed as an instant. A zone-less date-time such as2026-08-30 18:19:25.947would be read as local time, so it is deliberately left opaque and compared verbatim — pinned by its own test.Blast radius (the Zone 2 question)
normaliseVersionTokenis the only place a record-data version token is compared or emitted. The repo has three other version lanes, and none can carry this defect: metadata items compare asha256:content hash (parentVersion/ ADR-0008), cluster KV compares a monotonicbigint, and metadata HTTP caching uses a content ETag.packages/restforwards the token and passescurrentVersionthrough without reading it.But the PM's "one seam, one behaviour" reading is half wrong in a way that matters: the seam has two limbs, and the broken representation was not merely compared internally — it was published. Repairing only the comparison would have left the 409 serving a
Date.toString()whose milliseconds are already gone, i.e. a token that can never match the record it names — converting a false conflict into an unresolvable one on the exact path (InlineEditSaveBarandoccSavere-key their retry toconflict.currentVersion) that is today the only way Postgres users can save at all. Both limbs are fixed here.That published change is wire-visible, and it was measured rather than assumed:
content/docs/api/wire-format.mdxalready documentscurrentVersionas an ISO-8601 UTC timestamp — the fix moves the implementation toward the documented contract, it does not change it.{ ifMatch: conflict.currentVersion }) and only formats it for display; nothing there constructs or parses theDate.toString()spelling.v2,rowversion-7).The accept side is strictly widening, and that is a property, not a hope. When either token is not an instant the comparison falls back to the verbatim strings, exactly as before; so any pair that matched before still matches, and no token a client sends today starts being refused — including a client still holding a pre-fix 409's
Date.toString()value across the upgrade (control 5 above, and a test). Only two spellings of one instant change verdict, from conflict to match.Clause 2 — my own reading
FIRES, and I concur with the PM; no upward revision. It changes when a guarded PATCH/DELETE is accepted versus rejected with 409, and it changes a published wire field — contract accept/reject behaviour on the content limb. No
packages/spec/src/**path and no new authorable surface. Worth putting to the reviewer: on both limbs this is a repair toward an already-documented contract, and the accept set only grows — but that is an argument for the review, not a reason to downgrade it. Please review atCONTRACT_REVIEW_TIERbefore this is flipped ready.Tests
packages/metadata-protocol/src/protocol.occ-version-token-instant.test.ts— 23 tests. They pin the property, not the spelling: a table of spelling pairs each naming one instant, a three-zone sweep with a non-vacuity control asserting the zones really do move the broken spelling, millisecond-resolution conflicts, the published token round-tripping, and the widening guarantee, checked against the pre-fix comparison rather than asserted (see the patch round below). No existing test was skipped, disabled, quarantined, allow-listed, deleted or re-baselined — the diff touches no existing test file, and the existing OCC pins (objectql/src/protocol-data.test.ts,metadata-protocol/src/protocol.update-path-id-wins.test.ts,rest/src/rest.test.ts) pass unmodified. None of them encoded the broken comparison: every fixture was ISO-on-both-sides, which is exactly why they were green throughout.Ablation, re-run on the committed implementation at
14c197f87e:canonicalVersionInstantneutralised so every token reports "not an instant", reducing the seam to the old verbatim compare. Mutation proved on disk by grep of both the injected marker (1) and the deleted anchor text (0), and by the blob hash moving6a08f324toddd2081b. Result: 11 of 23 fail — and the 12 that stay green are precisely the ones encoding pre-fix behaviour that must not change (the SQLite ISO-on-both-sides row, the opaque tokens, both empty-token opt-outs). The differential sweep fails on its own non-vacuity assertion — "the corpus must contain at least one pair this change newly accepts" — which is the honest reason: under the mutation the accept set stops growing. No rebuild leg: the suite reaches the subject through a relative source import, proven by the mutation reddening it with no build at all. Restored undertrap ... EXIT INT TERMwith an absolute repo root, proved by whole-treegit status --porcelainempty,git diff HEADempty, and the blob hash back to6a08f324.Gates
Whole derived family re-run at the final commit
14c197f87e—node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, now 64 commands (the docs edit below pulls in 21 more families), 62 green. Plus the whole-repopnpm lint(eslint . --no-inline-config, exit 0 — not a narrowed run),pnpm --filter @objectstack/metadata-protocol test(2040 passed, 10 skipped, 0 failed),pnpm check:type-check-debt(29 ledger entries re-measured, none above its recorded number), andcheck-nul-bytes(7522 files, clean).Two report PREREQUISITE NOT MET (exit 3), which is NOT MEASURED and not a red:
check-test-completenessneeds a savedturbo run testlog that only CI produces, andcheck-half-statesneeds a real GitHub credential this container does not have.scripts/engine-double-contract.pinned.jsongrew by 3 rows: the gate retains a pin per (file, verb) and asked for the new suite's double to be recorded —--write, 3 added, 0 lost. That is new pinned coverage, the opposite of a relaxed baseline.Patch round — two items, one validated push
Both reproduced first, then fixed, then shown passing. Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
1.
check-system-context-censusline rot.content/docs/permissions/system-context.mdxanchors elevation reads by line number, and this PR pushed row 21's read down. Verified a pure shift before repairing rather than assumed, because the gate refuses to guess a population change and only rewrites rot: the anchored statement is byte-identical across the move (sha256db885f75...), theisSystemoccurrence count in the file is 9 on both trees, and the new line is the first statement ofstripReadonlyForInsert— exactly what row 21 describes. Repaired with the gate's own--fix; no false refusal, so #13490 did not reproduce here. One anchor rewritten, no prose touched, nothing baselined or exempted. It shifted a second time when the remedy below added ten lines above the same function, which the final-commit gate run caught; both re-anchors are folded in.2. Contract review finding:
If-Match: ""flipped ACCEPT to 409. Real, and mine. An empty entity-tag is empty only after the RFC-7232 quotes come off; the pre-fix seam returned the bare string, so that case handed every caller a falsy''and they short-circuited into "no token supplied". Wrapping the result in an object made it always truthy, so the check began running and''mismatched verbatim. Remedy is the conservative one — re-apply the emptiness test after the strip, byte-identical to70fe54891e. Whether an empty entity-tag should be able to disable OCC is a contract question and is filed separately; a p1 bug-fix PR does not silently install a new rejection.The widening claim is no longer prose. It was believed by three readers while one token had already flipped, so the suite now carries a differential sweep: 8 stored shapes by 15 client tokens, each pair judged against the pre-fix comparison reproduced verbatim, failing on any pair accepted before and refused now. Run against the unfixed tree it reports exactly the five
""pairs and nothing else — which is how the review finding is known to be the whole regression rather than one instance of a class. The suite is now 23 tests, all passing.What this does not cover
There is no automated live-Postgres coverage of this seam. The
Temporal Conformance (live PG + MySQL)job runspnpm --filter @objectstack/driver-sql test, and@objectstack/metadata-protocolhas no driver dependency and must not grow one, so the live evidence above is a measurement I made rather than a pin the repo now carries. Recorded as a finding in #13567 (out of scope here: it is a CI-topology decision, not a bug fix).