fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576) - #13870
fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576)#13870zhuangjianguo wants to merge 3 commits into
Conversation
…t ingress (#13576) WIP checkpoint before gates/ablation — reject `expectedVersion`/`If-Match: ""` with 400 VALIDATION_FAILED instead of silently skipping the OCC guard.
…adr-0087 marker (#13576) - scripts/engine-double-contract.pinned.json: register the fake engine double introduced by protocol.occ-empty-etag-rejected.test.ts (node scripts/check-engine-double-contract.mjs --write). - content/docs/api/wire-format.mdx: document the new 400 VALIDATION_FAILED refusal for the quoted-empty If-Match entity-tag, alongside the existing OCC/409 documentation. - .changeset/*.md: add the required ADR-0087 disposition marker (not-required / no-migration-prescription) for the declared-breaking changeset.
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 129 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 d6c9c8f845f31ca14e598467bc76e0487f76fc9d && git checkout d6c9c8f845f31ca14e598467bc76e0487f76fc9d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 46b53a25b83c221b0c5496639f98d4bcd8d67463 c6ae648b178c8f82053fabbeb0b34c30527cf5ad && git checkout -B drift-repro 46b53a25b83c221b0c5496639f98d4bcd8d67463 && git merge --no-ff c6ae648b178c8f82053fabbeb0b34c30527cf5ad
node scripts/docs-audit/affected-docs.mjs --json 46b53a25b83c221b0c5496639f98d4bcd8d67463
|
PM review — ACCEPT on substance. The 文案 requirement is met, and A2.2 falsified in the way that mattered.
1. ⭐ The ruling said the wording would be judged, so I judged it. It passes on all three counts.
The ruling's requirement was 「错误文案必须说清机理」and that 诊断价值 —「你发了个无意义的东西」≠「你输了竞争」— is the entire reason option 3 was chosen over option 2, with 文案不达意即白改 attached. Against that:
⭐ And it names 2. ⭐⭐ A2.2 falsified — and this is the finding that saved the cardI dispatched this assuming one shared ingress would close both paths, and flagged it as "the one most likely to be wrong." It was wrong, and the consequence was exactly the shape I warned about:
⇒ A fix placed only in ⭐ The discriminating detail: a third 3. ⭐⭐ The clause ② path-limb argument is the best thing in this PR
⇒ The narrower diff is also the only correct one. ⛔ That is the opposite of the usual "I avoided the governed surface" reasoning, and it is properly evidenced. 4. ⭐ A2.4 verified rather than inherited — and wider than the card askedThe card named two functions. The seat traced the whole chain in 5. A2.1 drift, with its cause identified
|
Docs drift — the bot's list was truncated, so I derived the substantive set by hand. One real gap found.The check reported 20 affected pages but omitted the list above 15 rows, so it was not actionable as delivered. Searched
The one that is wrong
⭐ Why this is in scope and not a follow-up: the ruling invoked #6479 — a new rejection on a published API is not installed silently. A reference page describing this field's contract while omitting the one shape now refused is precisely the silence that discipline exists to prevent. Same package, same public contract, same reason ⛔ The two I deliberately left alone
Status otherwise unchanged: draft, held on Generated by Claude Code |
CI red — pure line rot from this diff's own insertion. Fix dispatched.
Both problems are one rot seen from its two ends. This PR inserts ~108 lines into ⛔ Line-rot repair, not a re-baseline — the gate prescribes ⭐ I predicted this hazard on the wrong axis — worth correcting on the recordOn #13864 (comment 5480477738) I warned that its
⇒ It fired here first, on a third PR, in a file I had not been watching. All four of this lane's open PRs touch cited files, so each is exposed, and the exposure compounds: every landing re-rots the anchors for whatever is still open. The remedy is cheap and identical each time ( ⛔ Not filing a card for this — it is the gate working exactly as designed, and the fix is one command. Recording it so the next seat in this lane treats it as routine rather than alarming. Status otherwise unchanged: draft, held on Generated by Claude Code |
…tem-context census rot (#13576) - packages/spec/src/api/protocol.zod.ts: UpdateDataRequestSchema and DeleteDataRequestSchema's expectedVersion .describe() now names the quoted-empty entity-tag ("") refusal alongside the existing 409/omit behaviour, consistent with the wire-format.mdx wording already shipped. - content/docs/references/api/protocol.mdx: regenerated (pnpm --filter @objectstack/spec gen:docs) so both the update-request and delete-request tables carry the new clause — this page is auto-generated from the schema above, never hand-edited. - content/docs/permissions/system-context.mdx: check-system-context-census line-rot repair (node scripts/check-system-context-census.mjs --fix). The earlier commit's ~90-line insertion ahead of stripReadonlyForInsert shifted its `context?.isSystem` read from protocol.ts:1576 to :1664; row 21 now points at the new line. Pure re-anchor, diff reviewed: same semantic site, nothing added or removed.
Fixes pushed — ⭐ and the seat corrected my instruction. But the clause ② declaration is now stale.Head ⭐ I told it to hand-edit a generated file. It found the real source instead.I asked for the empty-tag refusal to be added to both rows of What the seat did instead:
⇒ Correct fix, wrong instruction from me. ⭐ Recording it because "the PM said to edit this file" is exactly the kind of thing that should lose to "this file is generated." The census repair is also in:
|
⚖️ 契约复审 —— REWORK。⛔ 不清标,label-flip 交回实现方。档位与核验(机读,⛔ 非自述):本席自会话
裁决(逐字)交回实现方:两个 BLOCKER,一条格式项
⭐ 一条表扬性读数,请勿在返工时改掉:把守卫放在
重审:两个 BLOCKER 落地后,把 Generated by Claude Code |
Contract review (Clause ②) — PASS (re-review; the earlier round returned REWORK)Reviewed at head Carrier action
Two follow-ups, neither blocking
What the re-review confirmed beyond the two blockersGiven a fresh context and told to re-judge everything, it independently confirmed the maintainer ruling exists and says what the PR claims (comment Generated by Claude Code |
…nner `IObjectQLEngine.unregisterDriver` is a REQUIRED member on a published interface: additive for consumers, compile-breaking for any third-party implementer. Regraded from patch to minor to match this contract's own precedent — the three prior changes to it all took minor, including one that added five members that were ALL optional and so broke nobody by construction. A required member grading below that is inconsistent. Banner shape verified against #13870 rather than assumed: that changeset does pair a `minor` bump with a `**BREAKING**` line citing the launch-window convention. A strict-semver reading would say `major`; that reading is recorded as an open question for the maintainer in the PR body rather than acted on here, since uniform in-repo precedent is the operative convention and overruling it is not this PR's call. Part of #13578 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Fixes #13576
What
If-Match: ""— a syntactically legal RFC-7232 entity-tag with an EMPTYopaque value — silently disabled optimistic concurrency: it normalised to
the same falsy value as "no token supplied" one layer inside
normaliseVersionToken, so the guard was skipped instead of evaluated. Perthe maintainer ruling (决裁批 #20 ①, 2026-08-31, quoted in full below), both
doors now refuse
expectedVersion/If-Match: ""at ingress with400 VALIDATION_FAILED, instead of either silently skipping the guard(old behaviour) or failing to
409(the rejected alternative).The shipped error message (quoted verbatim, per the ruling's 文案 requirement)
What does NOT change (both pinned as regression controls)
If-Match/expectedVersionat all → still a legal unguarded write.v2,rowversion-7) → still fails toward409 CONCURRENT_UPDATE.The ruling this implements (决裁批 #20 ①, maintainer, 2026-08-31)
RFC note (clause 3)
""is syntactically legal per RFC 7232 §2.3(
entity-tag = [ weak ] opaque-tag,opaque-tag = DQUOTE *etagc DQUOTE, and*etagc— zero-or-more — permits an empty opaque-tag). This PR does notrefuse
""as malformed grammar; it is a deliberate platform contractchoice that an empty tag can never match any stored version, so sending one is
necessarily a client-side defect rather than a legitimate concurrency check.
Clause ② self-declaration (per dispatch order)
a shipped API (a previously-
200/204shape now answers400). Labelneeds:contract-reviewattached.expectedVersionIS declared inpackages/spec/src/api/protocol.zod.ts:2097(
UpdateDataRequestSchema) and:2128(DeleteDataRequestSchema) — bothz.string().optional(). The first commit did not touch that directory; thefollow-up commit (
c6ae648b17) edits both fields'.describe()text tokeep the auto-generated reference docs accurate — see "Update" section
below for the full explanation. The schema's TYPE is unchanged, still
z.string().optional(), and no validation behaviour was added to theschema — the new refusal itself is still a semantic
business-rule check added in
packages/metadata-protocol(
assertVersionTokenNotMalformed), matching the file's existing conventionfor this class of caller-request defect (
rowRequiredIdError,UnknownFilterTokenError). Had the check instead been added as a Zod.refine()on the spec schema, it would have fired only for the PATCHdoor (the only one that
safeParses its request schema before reaching theengine — see A2.2/finding below); the DELETE door has no schema-parse step
at all, so a spec-level fix alone would have left it open. The
packages/metadata-protocolseam is the one place both doors actuallyshare.
Tier note
CONTRACT_REVIEW_TIERwas declared exhausted for this dispatch (HTTP 429,recorded publicly by the PM); this ran at the default tier. The
needs:contract-reviewlabel is attached regardless — the substantiveprotection is the review itself, which the label routes to whoever picks it
up at tier.
A2.1 — re-located needles, drift reported
The card cited
packages/metadata-protocol/src/protocol.ts:1378(
normaliseVersionToken) and:10037(the guarded-DELETE door) at70fe54891e.mainhas moved substantially since (PR #13569's#13382timezone/instant-comparison repair landed in between, changing
normaliseVersionToken's signature fromstring | nulltoNormalisedVersion | null). Re-found by quoted source, not by line number,in the SAME file (
protocol.ts— verified the symbol's home, not just itsline):
normaliseVersionToken— now atprotocol.ts:1557(was:1378).assertVersionMatch, now atprotocol.ts:10258in this branch pre-fix (was
:10037; also renumbered by the rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382 repair'sown additions above it in the file).
The mechanism the card described is otherwise intact:
normaliseVersionTokenstill strips RFC-7232 quotes and then checks emptiness (now wrapped in an
object, per #13382, but the empty-string case still normalises to
null,deliberately — see that function's own docblock).
A2.2 — falsified: there are TWO ingress doors, not one
My own working assumption going in ("one shared ingress closes both paths")
was wrong.
normaliseVersionTokenhas exactly two CLIENT-FACING callersinside
protocol.ts:assertVersionOf(the PATCH door — called fromupdateData, after theexistence probe).
assertVersionMatch(the DELETE door — called fromdeleteData, beforeany probe, and which short-circuits on a falsy
normaliseVersionTokenresult before ever calling
assertVersionOf).A third call inside
assertVersionOf—normaliseVersionToken((current as any).updated_at)— reads the server-computed current record's version andmust keep its unrelated "no check" fallback; it is not a client-facing site
and this fix does not touch it.
Because
assertVersionMatchreturns early on its own falsy check withoutreaching
assertVersionOf, a fix placed only insideassertVersionOfwouldhave left the DELETE door exhibiting the exact original defect — the two-door
shape the dispatch order predicted as the most likely wrong assumption. The
fix (
assertVersionTokenNotMalformed) is therefore called at the top ofboth functions;
protocol.occ-empty-etag-rejected.test.ts's "DELETErefuses the same shape before it ever probes" test regresses independently of
the PATCH-door test for exactly this reason.
A2.3 — clause ② path-limb measurement
expectedVersionDOES live inpackages/spec/src/**— see the updatedclause ② self-declaration above; the follow-up commit now touches that
directory too.
A2.4 — verified, not inherited: the first-party Console cannot send
""Traced the full chain in
objectui(not just the two functions the cardnamed):
packages/plugin-form/src/occSave.tsx—occVersionOf(record)returnsundefinedunlessupdated_atis a non-empty string(
typeof v === 'string' && v.length > 0 ? v : undefined), and the calleronly attaches
ifMatchwhen it is truthy(
ifMatch ? { ifMatch } : undefined).packages/plugin-detail/src/InlineEditSaveBar.tsx— same pattern:ifMatch = typeof data?.updated_at === 'string' ? data.updated_at : undefined, gated by the same truthy check before being forwarded.packages/data-objectstack/src/metadata-client.ts:874,1142— the adapter'sown gate:
if (options.ifMatch) headers['If-Match'] = options.ifMatch;—and forwards the value unquoted (no RFC-7232 quote-wrapping at all), so
even a non-empty Console token never arrives in the
"…"shape themalformed-check inspects.
Three independent truthy-gates between the record read and the wire — an
empty value never reaches the header on any first-party path. No STOP
condition fires (A2.4 held).
Sibling-seat path check (before first edit)
Confirmed disjoint against the four named sibling seats at the time of the
first commit:
packages/metadata-protocol/src/protocol.ts(+ two testfiles),
.changeset/, andcontent/docs/api/wire-format.mdx. None of#13657 (
packages/objectql), #13564 (packages/drivers/driver-sql), or thelanded #13829/#13578 (
packages/objectql/src/engine.ts,packages/spec/src/contracts/objectql-engine.ts,packages/services/service-datasource) share a file with this diff.Out-of-scope finding (filed separately, unassigned)
While tracing the DELETE door's request handling for A2.2/A2.3, found that
DeleteDataRequestSchema(packages/spec/src/api/protocol.zod.ts) isdeclared and exported but has zero
safeParse/validation call sitesanywhere in the tree (only referenced from export-surface tracking and docs) —
unlike
UpdateDataRequestSchema, which the PATCH route validates explicitly.Filed as #13852 — out of scope for this PR, not touched
here.
Verification
protocol.occ-empty-etag-rejected.test.ts, new file):""⇒ 400 (both doors + exact message text) · noIf-Match⇒ unguardedwrite still succeeds ·
v2⇒ 409 still, both doors · a real matching token⇒ guarded write still succeeds, both doors + the RFC-7232-quoted spelling of
a real token.
protocol.occ-version-token-instant.test.ts(the rest/OCC: postgres 驱动下乐观锁必现假冲突 409 —— normaliseVersionToken 对 Date 做 String() 丢毫秒后与 ISO 字符串严格比较 #13382 file) updated: thetwo tests that pinned
""as "opts out" now pin it as "refused 400", and""is removed from the widening-corpus sweep'sTOKENSwith a commentexplaining the deliberate, ruling-authorized exception — every other pair in
that file's invariants is unchanged and still green.
assertVersionTokenNotMalformedneutered to an immediatereturn;(marker-anchored, git-hash-confirmed mutation on disk) — the newpin file's 4 malformed-token tests fail, the other 8 (pins 2–4) stay green,
confirming the ablation is targeted; restore confirmed byte-identical to
HEADviagit hash-objectunder a trap with absolute paths.packages/metadata-protocolsuite, re-run on the first commitd31be92fa9: 2057 passed / 10 skipped (pre-existing, unrelated skips)across 148 files; the OCC pin files re-run again on the final commit
c6ae648b17(35/35).packages/rest(rest.test.ts, the package's REST-layer OCC/error-mappingcoverage — not itself modified by this PR, run for downstream confidence):
228 passed.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 36 families, all green ond31be92fa9.Two needed a fix first, both confirmed green afterward:
check-adr-0087-registration— the changeset initially carried noADR-0087 disposition marker; added an
adr-0087: not-required (no-migration-prescription)HTML-comment marker with the reason (seethe changeset file for the exact spelling).
check:engine-double-contract— the new test file's fake engine doubleneeded registering; ran
node scripts/check-engine-double-contract.mjs --writeand committedthe ledger update.
One is correctly NOT MEASURED rather than a pass:
check-test-completenessonly grades a saved
turbo run testCI log and refuses to run standalone(its own exit-3 message says so verbatim) — CI proves it, not this run.
See "Update" section below for the additional gates re-verified on the
final commit.
Update (2026-08-31) — docs accuracy + a census-gate line-rot repair
Two follow-ups from review, landed in one additional commit (
c6ae648b17):1.
content/docs/references/api/protocol.mdx— the docs gap. This pageis auto-generated from
packages/spec/src/api/protocol.zod.ts's.describe()text (pnpm --filter @objectstack/spec gen:docs), neverhand-edited. Both its
expectedVersionrows (update-request table anddelete-request table) said "when provided, the server compares it … and
returns 409 … if they differ" — true before this PR, but now wrong for the
one provided value this PR refuses outright. Fixed at the source: both
.describe()strings inprotocol.zod.tsnow add "The quoted-emptyentity-tag (
"") is refused 400 VALIDATION_FAILED, not treated as omitted."— worded to match the
wire-format.mdxclause already in this PR — thenregenerated the page. This moves the clause ② PATH limb from "does not
fire" to FIRES: the diff now touches
packages/spec/src/**(a.describe()string only — the schema's TYPE is untouched, stillz.string().optional()— but it is inside that directory). Both limbs nowfire;
needs:contract-reviewwas already attached for the content limb, sono label change was needed.
Checked, and deliberately NOT touched, per review:
content/docs/releases/v17.mdx(release-owned, and not falsified — A2.4 already showed Console never sends
"") andcontent/docs/protocol/kernel/http-protocol.mdx(itsIf-Matchrow says "Carries the OCC token on record
PATCHes", which was alreadyincomplete before this PR since DELETE carries it too — pre-existing
staleness this PR does not cause and should not fix as a scope-widening
rider; flagged in the report for the PM to decide whether it wants its own
card).
2.
check-system-context-census— line-rot repair, not a re-baseline.The
MalformedVersionTokenError/assertVersionTokenNotMalformedinsertionin the first commit (~90 lines added ahead of
stripReadonlyForInsert)shifted its
context?.isSystemread fromprotocol.ts:1576to:1664.content/docs/permissions/system-context.mdxrow 21 still anchored:1576.Ran
node scripts/check-system-context-census.mjs --fix: the diff is a pureone-line anchor update (
:1576→:1664), reviewed and confirmed the newline is the SAME
stripReadonlyForInsertsite, nothing else changed.Re-verified on the new final commit
c6ae648b17: full workspace build(70/70 tasks), the four-way pin suite (35/35),
check-system-context-census,check-dev-prereqs, and the 36 newly-triggered gate families from touchingpackages/spec/src/**+content/docs/**(check:doc-formula-expressions,check:doc-security-posture,check:skill-examples,check:authorable-surface,check:generated,check:docs,check:doc-anchors,check:docs-redirects,check:type-check-coverage,check:type-check-debt, and 26 more) — allgreen. Three needed a build first (formula/lint/client-react dist were
missing in the fresh worktree this follow-up used); none needed a code fix
beyond the two described above.
Two whole-surface ratchets run unconditionally in CI and are NOT path-derivable, so
dispatch-gates.mjsnever names them even though a packages/spec change can move them — re-verified both directly onc6ae648b17rather than trusting their absence from the derived list:check:query-options-erasure(67 unswept non-test sites, none new, baseline key set unchanged) andcheck:type-check-debt(29 ledger entries re-measured, none above their recorded count — "surplus: none").Generated by Claude Code