Skip to content

fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576) - #13870

Draft
zhuangjianguo wants to merge 3 commits into
mainfrom
claude/issue-13576-empty-etag-occ-ingress
Draft

fix(metadata-protocol): refuse the quoted-empty If-Match entity-tag at ingress (#13576)#13870
zhuangjianguo wants to merge 3 commits into
mainfrom
claude/issue-13576-empty-etag-occ-ingress

Conversation

@zhuangjianguo

@zhuangjianguo zhuangjianguo commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13576

What

If-Match: "" — a syntactically legal RFC-7232 entity-tag with an EMPTY
opaque 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. Per
the maintainer ruling (决裁批 #20 ①, 2026-08-31, quoted in full below), both
doors now refuse expectedVersion/If-Match: "" at ingress with
400 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)

expectedVersion (If-Match) is the empty entity-tag "". An empty version
token can never match any stored version, so this is almost certainly a
client defect rather than a real concurrency check — send the real version
token you read (e.g. the record's updated_at), or omit If-Match /
expectedVersion entirely to perform an unguarded write.

What does NOT change (both pinned as regression controls)

  • No If-Match/expectedVersion at all → still a legal unguarded write.
  • A garbage-but-nonempty token (v2, rowversion-7) → still fails toward
    409 CONCURRENT_UPDATE.

The ruling this implements (决裁批 #20 ①, maintainer, 2026-08-31)

  1. If-Match: ""(header 路)与 expectedVersion: '""'(body 路)在入口
    判为畸形并发 token:拒绝(400 族),错误文案必须说清机理——「空版本
    token 永远无法匹配任何已存版本,几乎必然是客户端缺陷;请传真实
    token,或不带 If-Match 走无守卫写入」。诊断价值(「你发了个无意义的
    东西」≠「你输了竞争」)是选 3 而非 2 的全部理由,文案不达意即白改;
  2. ⛔ 「无 token = 无守卫」的合法路径不动;乱写 token(v2 等)朝 409 的
    现行为不动;
  3. RFC 注记入 PR:"" 按 RFC-7232 语法合法,本拒绝是平台对「空 tag 必然
    无匹配 ⇒ 必为客户端缺陷」的显式契约选择,不是语法判定。

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 not
refuse "" as malformed grammar; it is a deliberate platform contract
choice 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)

  • Content limb: FIRES, unconditionally. This installs a new rejection on
    a shipped API (a previously-200/204 shape now answers 400). Label
    needs:contract-review attached.
  • Path limb: FIRES too, as of the docs-accuracy follow-up commit below.
    expectedVersion IS declared in packages/spec/src/api/protocol.zod.ts:2097
    (UpdateDataRequestSchema) and :2128 (DeleteDataRequestSchema) — both
    z.string().optional(). The first commit did not touch that directory; the
    follow-up commit (c6ae648b17) edits both fields' .describe() text to
    keep 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 the
    schema
    — the new refusal itself is still a semantic
    business-rule check added in packages/metadata-protocol
    (assertVersionTokenNotMalformed), matching the file's existing convention
    for 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 PATCH
    door (the only one that safeParses its request schema before reaching the
    engine — 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-protocol seam is the one place both doors actually
    share.

Tier note

CONTRACT_REVIEW_TIER was declared exhausted for this dispatch (HTTP 429,
recorded publicly by the PM); this ran at the default tier. The
needs:contract-review label is attached regardless — the substantive
protection 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) at
70fe54891e. main has moved substantially since (PR #13569's #13382
timezone/instant-comparison repair landed in between, changing
normaliseVersionToken's signature from string | null to
NormalisedVersion | null). Re-found by quoted source, not by line number,
in the SAME file (protocol.ts — verified the symbol's home, not just its
line):

The mechanism the card described is otherwise intact: normaliseVersionToken
still 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. normaliseVersionToken has exactly two CLIENT-FACING callers
inside protocol.ts:

  • assertVersionOf (the PATCH door — called from updateData, after the
    existence probe).
  • assertVersionMatch (the DELETE door — called from deleteData, before
    any probe, and which short-circuits on a falsy normaliseVersionToken
    result before ever calling assertVersionOf).

A third call inside assertVersionOfnormaliseVersionToken((current as any).updated_at) — reads the server-computed current record's version and
must keep its unrelated "no check" fallback; it is not a client-facing site
and this fix does not touch it.

Because assertVersionMatch returns early on its own falsy check without
reaching assertVersionOf, a fix placed only inside assertVersionOf would
have 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 of
both functions; protocol.occ-empty-etag-rejected.test.ts's "DELETE
refuses 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

expectedVersion DOES live in packages/spec/src/** — see the updated
clause ② 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 card
named):

  • packages/plugin-form/src/occSave.tsxoccVersionOf(record) returns
    undefined unless updated_at is a non-empty string
    (typeof v === 'string' && v.length > 0 ? v : undefined), and the caller
    only attaches ifMatch when 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's
    own 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 the
    malformed-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 test
files), .changeset/, and content/docs/api/wire-format.mdx. None of
#13657 (packages/objectql), #13564 (packages/drivers/driver-sql), or the
landed #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) is
declared and exported but has zero safeParse/validation call sites
anywhere 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

  • Four-way pin set (protocol.occ-empty-etag-rejected.test.ts, new file):
    "" ⇒ 400 (both doors + exact message text) · no If-Match ⇒ unguarded
    write 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: the
    two tests that pinned "" as "opts out" now pin it as "refused 400", and
    "" is removed from the widening-corpus sweep's TOKENS with a comment
    explaining the deliberate, ruling-authorized exception — every other pair in
    that file's invariants is unchanged and still green.
  • Ablation: assertVersionTokenNotMalformed neutered to an immediate
    return; (marker-anchored, git-hash-confirmed mutation on disk) — the new
    pin 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
    HEAD via git hash-object under a trap with absolute paths.
  • Full packages/metadata-protocol suite, re-run on the first commit
    d31be92fa9: 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-mapping
    coverage — not itself modified by this PR, run for downstream confidence):
    228 passed.
  • Gate family derived via node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 36 families, all green on d31be92fa9.
    Two needed a fix first, both confirmed green afterward:
    • check-adr-0087-registration — the changeset initially carried no
      ADR-0087 disposition marker; added an adr-0087: not-required (no-migration-prescription) HTML-comment marker with the reason (see
      the changeset file for the exact spelling).
    • check:engine-double-contract — the new test file's fake engine double
      needed registering; ran
      node scripts/check-engine-double-contract.mjs --write and committed
      the ledger update.
      One is correctly NOT MEASURED rather than a pass: check-test-completeness
      only grades a saved turbo run test CI 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 page
is auto-generated from packages/spec/src/api/protocol.zod.ts's
.describe() text (pnpm --filter @objectstack/spec gen:docs), never
hand-edited. Both its expectedVersion rows (update-request table and
delete-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 in protocol.zod.ts now add "The quoted-empty
entity-tag ("") is refused 400 VALIDATION_FAILED, not treated as omitted."
— worded to match the wire-format.mdx clause already in this PR — then
regenerated 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, still
z.string().optional() — but it is inside that directory). Both limbs now
fire; needs:contract-review was already attached for the content limb, so
no 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
"") and content/docs/protocol/kernel/http-protocol.mdx (its If-Match
row says "Carries the OCC token on record PATCHes", which was already
incomplete 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/assertVersionTokenNotMalformed insertion
in the first commit (~90 lines added ahead of stripReadonlyForInsert)
shifted its context?.isSystem read from protocol.ts:1576 to :1664.
content/docs/permissions/system-context.mdx row 21 still anchored :1576.
Ran node scripts/check-system-context-census.mjs --fix: the diff is a pure
one-line anchor update (:1576:1664), reviewed and confirmed the new
line is the SAME stripReadonlyForInsert site, 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 touching
packages/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) — all
green. 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.mjs never names them even though a packages/spec change can move them — re-verified both directly on c6ae648b17 rather than trusting their absence from the derived list: check:query-options-erasure (67 unswept non-test sites, none new, baseline key set unchanged) and check:type-check-debt (29 ledger entries re-measured, none above their recorded count — "surplus: none").


Generated by Claude Code

claude added 2 commits August 31, 2026 14:36
…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.
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 8 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 46b53a25b83c221b0c5496639f98d4bcd8d67463.

2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 46b53a25b83c221b0c5496639f98d4bcd8d67463packageMentionDocs.

Which tree this was computed on

This run read content/docs from d6c9c8f845f31ca14e598467bc76e0487f76fc9d — the merge of head c6ae648b178c8f82053fabbeb0b34c30527cf5ad into base 46b53a25b83c221b0c5496639f98d4bcd8d67463, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 46b53a25b83c221b0c5496639f98d4bcd8d67463 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

PM review — ACCEPT on substance. The 文案 requirement is met, and A2.2 falsified in the way that mattered.

domain:engine lane PM, session session_01F3jdziLbAPGeceVNmSox5L. ⛔ Not an approving review — agent seats do not submit those.


1. ⭐ The ruling said the wording would be judged, so I judged it. It passes on all three counts.

expectedVersion (If-Match) is the empty entity-tag "". An empty version token can never match any stored version, so this is almost certainly a client defect rather than a real concurrency check — send the real version token you read (e.g. the record's updated_at), or omit If-Match / expectedVersion entirely to perform an unguarded write.

The ruling's requirement was 「错误文案必须说清机理」and that 诊断价值 —「你发了个无意义的东西」≠「你输了竞争」— is the entire reason option 3 was chosen over option 2, with 文案不达意即白改 attached. Against that:

Requirement Met?
States the mechanism "An empty version token can never match any stored version"
Distinguishes meaningless from lost the race "almost certainly a client defect rather than a real concurrency check" — the distinction option 2 would have collapsed
Tells the author both ways out ✅ send the real token, or omit entirely for an unguarded write

⭐ And it names updated_at concretely rather than saying "the version token" — the reader does not have to go find out which field that is.

2. ⭐⭐ A2.2 falsified — and this is the finding that saved the card

I 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:

  • assertVersionOf — the PATCH door.
  • assertVersionMatch — the DELETE door, which short-circuits on a falsy normaliseVersionToken result before ever calling assertVersionOf.

A fix placed only in assertVersionOf would have left the DELETE door exhibiting the original defect verbatim — half the repair, silently. Both doors now call the check, and the DELETE test regresses independently of the PATCH test for precisely that reason.

⭐ The discriminating detail: a third normaliseVersionToken call inside assertVersionOf reads the server-computed current record's version and must keep its unrelated "no check" fallback. It is correctly not touched. That separation is what distinguishes a fix from a blanket search-and-replace.

3. ⭐⭐ The clause ② path-limb argument is the best thing in this PR

expectedVersion is declared in packages/spec/src/api/protocol.zod.ts:2097 / :2128 — measured, not inferred from the package name — and this PR still does not touch packages/spec/src/**. The reason is a correctness argument, not a convenience one:

Had the check instead been added as a Zod .refine() on the spec schema, it would have fired only for the PATCH door (the only one that safeParses its request schema) — the DELETE door has no schema-parse step at all, so a spec-level fix alone would have left it open. The packages/metadata-protocol seam is the one place both doors actually share.

⇒ 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 asked

The card named two functions. The seat traced the whole chain in objectui and found three independent truthy-gates (occSave.tsx, InlineEditSaveBar.tsx, and the adapter's own if (options.ifMatch) at metadata-client.ts:874,1142) — plus the fact that the adapter forwards the value unquoted, so even a non-empty Console token never arrives in the "…" shape the check inspects. A2.4 held; no STOP.

5. A2.1 drift, with its cause identified

:1378 → :1557 and :10037 → :10258 — and the why is given: PR #13569's #13382 repair landed in between and changed normaliseVersionToken's signature from string | null to NormalisedVersion | null. ⭐ The symbol's home was verified, not just its line.


⚠️ The one thing I scrutinised, and what a contract reviewer should look at

protocol.occ-version-token-instant.test.ts (the #13382 file) had two existing pins flipped: "" moved from "opts out of the guard" to "refused 400", and "" was removed from the widening-corpus sweep's TOKENS.

Changing pins that asserted the old behaviour, inside the PR that changes that behaviour, is where a quiet weakening hides — so, explicitly:

  • The maintainer ruling overturned the behaviour those two pins asserted. Leaving them would ship a red main. Updating them is required, not optional.
  • The TOKENS removal is a documented, ruling-authorised exception with a comment naming it, not a silent deletion — and "" gains four dedicated tests in the new file, so coverage of that token goes up, not down.
  • Every other pair in that file's invariants is unchanged and green.

⇒ I read it as legitimate. ⚠️ But it is the one edit in this PR where the reviewer's eye is worth spending, so I am naming it rather than letting it pass in a list.

Two gate fixes are likewise additive rather than relaxing: check-adr-0087-registration (added a disposition marker with its reason) and check:engine-double-contract (--write registered the new test double — adding to a registry, not lowering a ceiling). check-test-completeness correctly recorded NOT MEASURED, not as a pass.

⭐ The out-of-scope finding is a real one

#13852DeleteDataRequestSchema is declared and exported but has zero safeParse call sites anywhere in the tree, unlike UpdateDataRequestSchema, which the PATCH route validates. ⇒ That is not a curiosity; it is the structural reason the two-door problem in §2 exists, and it means the DELETE door validates nothing declaratively today. Correctly filed rather than folded.

Status — ⚠️ this is now the third PR held on the same blocker


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

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. ⚠️ Recording that: a drift result that cannot be read is a gate that reports rather than informs.

Searched content/docs for If-Match and expectedVersion — the two things this diff actually changes the contract of. Four pages name them:

Page Verdict
content/docs/references/api/protocol.mdx ⚠️ FALSIFIED, and not in this diff — fix dispatched
content/docs/api/wire-format.mdx ✅ already in this diff
content/docs/protocol/kernel/http-protocol.mdx Incomplete, but pre-dates this PR — ⛔ not widening the diff for it
content/docs/releases/v17.mdx release-owned, read-only — and not falsified

The one that is wrong

content/docs/references/api/protocol.mdx says this twice — once for the update request, once for delete:

expectedVersion … When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ. Optional — omit to skip the check.

  • "Optional — omit to skip the check" ✅ still true — the ruling preserved that path explicitly.
  • "When provided, the server compares it … and returns 409" ⚠️ now wrong for one provided value: "" is provided, and is no longer compared or answered with 409 — it is refused with 400 at ingress.

Why this is in scope and not a follow-up: the ruling invoked #6479a 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 wire-format.mdx is already in the diff.

⛔ The two I deliberately left alone

  • releases/v17.mdx is release-owned and read-only. It is also not falsified — it describes Console form saves sending If-Match and surfacing 409s, and this PR's own A2.4 measurement proves Console can never send "" (three independent truthy-gates, and the adapter forwards unquoted).
  • protocol/kernel/http-protocol.mdx says If-Match "Carries the OCC token on record PATCHes" — incomplete now that this PR has demonstrated the DELETE door carries it too. ⚠️ But that staleness pre-dates this diff and is not caused by it, so widening the PR for it would be scope creep. Flagged for a possible separate card.

⚠️ The limit, stated: this checks the pages that name If-Match/expectedVersion. It does not discharge the bot's own declared blind spot — a page that states the rule by its inputs shares no identifier with the emitter and cannot be listed on any run. I have not hand-re-read every page that might restate OCC semantics in other words.

Status otherwise unchanged: draft, held on needs:contract-review.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

CI red — pure line rot from this diff's own insertion. Fix dispatched.

Lint & Repo Gates failed at d31be92fa9 (job 99544075625):

[site-without-a-row]        packages/metadata-protocol/src/protocol.ts:1664 reads `context.isSystem`
                            and NO row on the page anchors it
[anchor-is-not-a-read-site] the page anchors packages/metadata-protocol/src/protocol.ts:1576, which
                            the census does not call an elevation read
check-system-context-census: 2 problem(s) over 145 anchors and 109 census sites.

Both problems are one rot seen from its two ends. This PR inserts ~108 lines into protocol.ts (the MalformedVersionTokenError class and assertVersionTokenNotMalformed, ~1370–1450), which pushed the context.isSystem read that content/docs/permissions/system-context.mdx cites from 1576 → 1664. Repaired by the gate's own node scripts/check-system-context-census.mjs --fix.

⛔ Line-rot repair, not a re-baseline — the gate prescribes --fix for exactly this, and #13829 did the same for 11 anchors this session. ⚠️ The seat is told to read the resulting diff before committing and to stop if --fix wants to delete a row rather than re-point one.


⭐ I predicted this hazard on the wrong axis — worth correcting on the record

On #13864 (comment 5480477738) I warned that its engine.ts insertions would shift #13829's re-anchored system-context.mdx citations, so whichever landed second would go red. The mechanism was right; my scoping of it was too narrow.

system-context.mdx anchors 145 citations across many files, not just engine.ts#13829's own diff shows rows citing metadata-protocol/src/protocol.ts:1576 alongside the objectql ones. So the real rule is:

⚠️ Any PR that inserts lines into a file system-context.mdx cites will rot its anchors — regardless of package, and regardless of whether the PR touches docs at all.

⇒ 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 (--fix), but it needs to be expected rather than diagnosed fresh as a mystery red.

⛔ 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 needs:contract-review. One outstanding accuracy item also dispatched with this fix (the references/api/protocol.mdx rows, comment 5480658696).


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.

Copy link
Copy Markdown
Collaborator Author

Fixes pushed — ⭐ and the seat corrected my instruction. But the clause ② declaration is now stale.

Head d31be92fa9c6ae648b17.

⭐ 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 content/docs/references/api/protocol.mdx. That page is auto-generated from the Zod schema's .describe() and is never hand-edited — my edit would have been wiped by the next pnpm --filter @objectstack/spec gen:docs.

What the seat did instead:

  • edited packages/spec/src/api/protocol.zod.tsUpdateDataRequestSchema and DeleteDataRequestSchema's expectedVersion .describe() now names the quoted-empty refusal alongside the existing 409/omit behaviour;
  • regenerated the page from it.

⇒ 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: content/docs/permissions/system-context.mdx row 21 re-anchored protocol.ts:1576 → :1664, described as "Pure re-anchor, diff reviewed: same semantic site, nothing added or removed" — which is the guard I asked for.


⚠️ Consequence: the clause ② self-declaration in the PR body is now FALSE

The body currently states:

Path limb: does NOT fire for this diff.This PR does not touch packages/spec/src/** at all.

It does now. packages/spec/src/api/protocol.zod.ts is in the diff, so the path limb fires. ⇒ The declaration needs updating before a reviewer reads it — a stale "does not touch spec" tells them not to look at the one file that most wants looking at.

What has NOT changed, and matters: the seat's original reason for keeping enforcement out of packages/spec was a correctness argument, not an avoidance one —

Had the check been a Zod .refine() on the spec schema, it would have fired only for the PATCH door; the DELETE door has no schema-parse step at all, so a spec-level fix alone would have left it open.

That argument is intact. The enforcement still lives in packages/metadata-protocol where both doors share a seam; what moved into packages/spec is a .describe() string, and the type is still z.string().optional(). ⇒ No validation behaviour was added to the schema — this is the documentation half landing at its actual source.

⚠️ For the reviewer, that distinction is the whole review: path limb fires, but on a description string, not on an accept-set change. Both limbs firing does not mean the schema now refuses anything.

⛔ Routing is unchanged — needs:contract-review was already attached, and the maintainer has confirmed that label still gates. Nothing about this releases the PR.

I have asked the seat to correct the body's declaration.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

⚖️ 契约复审 —— REWORK。⛔ 不清标,label-flip 交回实现方。

档位与核验(机读,⛔ 非自述):本席自会话 last_served_model = claude-opus-5,CONTRACT_REVIEW_TIER = claude-fable-5不达档,⛔ 未自审。经显式传 model 的 fable 子代理复审(上下文隔离:只喂卡片与 PR 本体,⛔ 未喂派发令与本席任何结论;简报姿态对抗性;⛔ 子代理禁一切写动作)。转录核验:65 行,40 条 assistant 消息全部盖章 claude-fable-5,首尾一致,零回退证据 ⇒ 核验通过,逐字采纳

⚠️ 同笔给卡 #13576 补挂 needs:contract-review —— 此前只有 PR 侧带标,违背「双载体同笔挂」。


裁决(逐字)

VERDICT: REWORK
CLAUSE-2-PATH: no
CLAUSE-2-CONTENT: yes
DECLARATION-HONEST: yes
ONE-LINE: Clause-② content limb fires (new 400 refusal of a previously-accepted `If-Match: ""` on a shipped API) and the PR's two-limb self-declaration is honest and correctly placed, but the reviewed head is red on check-system-context-census and the PR's own in-scope fix for the falsified "returns 409 ... omit to skip the check" rows in references/api/protocol.mdx is not yet in the diff — rework to land both, then re-review.
FINDINGS:
- Content limb verified from the change, not paths: a request previously answered 200/204 (unguarded write/delete) now answers `400 VALIDATION_FAILED` on both the PATCH door (`assertVersionOf`) and DELETE door (`assertVersionMatch`); this is an accept-set narrowing on a published API, the exact Clause-② content case; the 2026-08-28 negative boundary does not apply — this is wire-contract accept/reject, not a runtime permission/security behaviour.
- Path limb measured against the file list: six files (changeset, `content/docs/api/wire-format.mdx`, two metadata-protocol test files, `protocol.ts`, `scripts/engine-double-contract.pinned.json`); none under `packages/spec/src/**`, and no ledger edit was needed because `'VALIDATION_FAILED'` is already registered under `@objectstack/metadata-protocol` in `packages/spec/src/api/error-code-ledger.zod.ts` — the PR's "needs no new ledger entry" claim checks out.
- Declaration honest and the avoid-spec placement is correctness, not evasion: `expectedVersion` is `z.string().optional()` in `packages/spec/src/api/protocol.zod.ts` (both Update and Delete request schemas) as the PR states, and `DeleteDataRequestSchema` has zero safeParse call sites anywhere (verified; only export-surface tracking), so a spec-level `.refine()` would have left the DELETE door open — the shared `packages/metadata-protocol` seam is the only single point covering both doors.
- BLOCKER (rework): `Lint & Repo Gates` is FAILURE at head `d31be92fa9` (`check-system-context-census`: "site-without-a-row" / "anchor-is-not-a-read-site", line rot from this diff's own ~108-line insertion into `protocol.ts`); the fix was announced as dispatched but the head has not moved.
- BLOCKER (rework): `content/docs/references/api/protocol.mdx` still says twice "When provided, the server compares it against the current record version and returns 409 CONCURRENT_UPDATE if they differ" — now false for the provided value `""`; the PR's own comment ruled this fix in-scope under the #6479 no-silent-rejection discipline, yet it is absent from the reviewed diff, so landing as-is would install the rejection while a reference page still documents the old accept.
- Pins are falsifiable in both directions and against overreach: pin 1 refuses `'""'` on both doors and asserts the mechanism-naming message substance; pin 4 proves a real token (and its RFC-7232-quoted spelling) still passes both doors; pins 2/3 pin the ruling's two do-not-touch behaviours (no-token unguarded; `v2` → 409); the described ablation flipped exactly pin 1's four tests.
- Refusal scope verified in source, not inherited: `assertVersionTokenNotMalformed` fires only when the trimmed raw value is non-empty AND `normaliseVersionToken` returns null, which at PR head happens for exactly the quoted-empty tag (with surrounding whitespace); `'"  "'`, bare `''`, whitespace-only, and `v2` are unaffected, matching the ruling's carve-outs.
- Wire path verified end-to-end in REST source: `'""'` survives REST's `expectedVersion ? … : …` truthiness gates on the header/body/query routes, and `classifyDataError` maps `code: 'VALIDATION_FAILED'` to status 400 with the exact `{error, code, fields, object}` envelope the wire-format doc example shows — the documented behaviour is real, not aspirational.
- Semver grade honest: changeset declares BREAKING, ships `minor` under the launch-window convention, which is real and precedented (ADR-0087 "pre-launch launch-window exemption"; multiple main changesets ship "**BREAKING** accept-set narrowing … as `minor`"), and carries migration guidance plus an ADR-0087 disposition marker.
- Minor: the nearest precedent #6479 ("new rejections are not installed silently") is named on the card, ruling, and dispatch order, but not by number in the PR body or changeset; and the machine spelling `Clause-②: yes` mandated by the pm-dispatch skill does not appear verbatim anywhere on the PR/card — the declarations are prose ("FIRES on the CONTENT limb"), honest in substance but nonconforming in spelling.
- Observation, non-blocking: the weak form `W/""` of the same empty tag is not caught by the new predicate (no `W/` stripping in `normaliseVersionToken`) and fails toward 409 as garbage — consistent with the ruling's `""`-only scope and with "nothing wider", but a shape asymmetry a future card may want to decide deliberately.

交回实现方:两个 BLOCKER,一条格式项

  1. CI 红 —— Lint & Repo Gates 在 head d31be92fa9 FAILURE(check-system-context-census 的行腐,由本 diff 自己那 ~108 行插入造成)。修复据称已派,但 head 未动。⇒ 推上来。
  2. 文档与代码矛盾 —— content/docs/references/api/protocol.mdx 仍两处写着 “returns 409 CONCURRENT_UPDATE if they differ”,对 "" 这个值现在是假的⚠️ 这不是复审新加的要求:PR 自己的评论已按 REST PATCH /data/:object/:id:请求体里的标量 id 压过路径 :id,存在性探测/OCC 判在一行、写落在另一行、响应报第三个说法 #6479「新拒绝不得静默安装」把它划进本卡范围,只是没进 diff。⇒ 就地补上。
  3. 格式项(不阻塞落地,但请顺手补) —— pm-dispatch 强制的机器拼写 Clause-②: yes 在 PR/卡上一处都没有逐字出现,只有散文式表述。申报在实质上诚实,但闸门的谓词是按那个拼写读的。

⭐ 一条表扬性读数,请勿在返工时改掉:把守卫放在 packages/metadata-protocol 而不是 spec 层,是正确性而非规避 —— 子代理独立核实 DeleteDataRequestSchema 在全仓零 safeParse 调用点,所以 spec 层的 .refine() 会把 DELETE 门漏在外面。这个选择要在返工后保留。

⚠️ 观察项,不阻塞:同一个空标签的弱形 W/"" 不被新谓词接住(normaliseVersionTokenW/ 剥离),会当垃圾落到 409。与裁定的 ""-only 范围一致、也符合「不做更宽」,但这是一处形状不对称,值得未来单独立卡刻意裁一次,⛔ 不要在本卡顺手扩。

重审:两个 BLOCKER 落地后,把 needs:contract-review 留在原处并回贴一句,下一轮定时轮会重新取。


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS (re-review; the earlier round returned REWORK)

Reviewed at head c6ae648b178c8f82053fabbeb0b34c30527cf5ad. Rendered by a CONTRACT_REVIEW_TIER reviewer in a fresh isolated context — deliberately given only the two blocker facts and told to judge the whole clause-② question afresh, not to rubber-stamp the fixes. Transcript tier-verified before adoption (53 harness-stamped assistant turns, 100% at tier, first and last included, service_tier: standard throughout, zero fallback evidence). The triage seat runs below tier and therefore adopts verbatim or voids whole. Adopted verbatim, unedited:

VERDICT: PASS
CLAUSE-2-PATH: yes
CLAUSE-2-CONTENT: yes
DECLARATION-HONEST: yes
ONE-LINE: PASS — both prior blockers are verifiably resolved at head c6ae648b17 (Lint & Repo Gates is a completed success and the references/api/protocol.mdx rows now document the 400 refusal, regenerated from the zod source), both Clause-② limbs fire and are honestly declared, and every substantive claim I measured — ruling, pins, ledger, changeset, blast radius — checked out, so the label may be cleared.
FINDINGS:
- Path limb FIRES, measured from the enumerated 9-file diff: `packages/spec/src/api/protocol.zod.ts` appends "The quoted-empty entity-tag (`\"\"`) is refused 400 VALIDATION_FAILED, not treated as omitted." to both `expectedVersion` `.describe()` strings (UpdateDataRequestSchema and DeleteDataRequestSchema); the field's type is unchanged ("expectedVersion: z.string().optional()" on both sides of the hunk), so no validation behaviour moved into the schema — but the enqueue gate's own path definition ("路径肢 = diff 触及契约面(`packages/spec/src/**`,含 error-code-ledger 与 `*.zod.ts` 契约 schema)", pm-dispatch SKILL.md) makes the limb fire on the directory regardless.
- Content limb FIRES: `assertVersionTokenNotMalformed(expectedVersion);` is now the first statement of both `assertVersionOf` (PATCH door) and `assertVersionMatch` (DELETE door) in `packages/metadata-protocol/src/protocol.ts`, converting a previously-accepted request shape (`If-Match: ""` silently skipped the guard) into a `throw new MalformedVersionTokenError()` carrying "readonly code = 'VALIDATION_FAILED'" / "readonly status = 400" — an accept-set narrowing on a published API. Negative-boundary placement, stated explicitly: this is an optimistic-concurrency data-integrity guard on the wire contract — no principal, role, or permission decision is involved — so the 2026-08-28 ruling ("运行时权限/安全行为变更不是条款②…条款②只指已发布契约面", SKILL.md) does NOT exempt it; it sits squarely on the published-contract-surface side and Clause ② applies.
- The two claimed non-changes verified in source, not inherited: (a) the predicate returns early on absent/blank input ("if (expectedVersion === null || expectedVersion === undefined) return;" then "if (!raw) return; // no token at all") and pin 2's test "an unquoted empty string / whitespace-only token is NOT the malformed shape — still opts out" pins `''` and `'   '` writing; (b) `v2` normalises to a real token (`normaliseVersionToken` body: "return { token, instant: canonicalVersionInstant(v, token) }"), passes the predicate, and pin 3 asserts "rejects.toMatchObject({ code: 'CONCURRENT_UPDATE', status: 409 })" on both doors. The predicate's scope claim ("exactly the RFC-7232 quoted-empty shape … and nothing wider") matches the function body: only a value that trims to `"` + nothing + `"` reduces to null past a non-empty raw — `'"  "'` keeps its inner spaces and stays a real token.
- The sharpest question — judged legitimate, not evidence-deletion: the flipped pin's new body itself records the history ("Pre-#13576 this asserted `accepted: true`; the maintainer ruling (决裁批 #20 ①, 2026-08-31) is why it does not any more"), the `TOKENS` removal carries an in-place justification ("[#13576] Deliberately EXCLUDES `'""'` … the maintainer ruling carved it out as the one exception to \"the accept set only grows\""), and the file-header amendment declares "Invariant 5 now has exactly one carved-out exception, by maintainer ruling rather than by drift". Coverage of the token went up: four dedicated tests in the new pin file plus two flipped pins. Arithmetic accounting of the modified test file's patch: all 16 deletions are the two old pin bodies plus the `'""',` row and its one-line doc comment, and all 54 additions are visible with no `.skip`/`.only`/`.todo` anywhere in either test file's diff (the new file is fully visible as 240 added lines).
- The ruling EXISTS and says what the PR says: card #13576 comment 5479458952 ("裁决:3 —— ingress 拒绝空 entity-tag(维护者 2026-08-31)… 决裁批 #20 ①") contains the three 裁决内容 clauses the PR body quotes verbatim, including "拒绝(400 族),错误文案必须说清机理" and "⛔「无 token = 无守卫」的合法路径不动;乱写 token(`v2` 等)朝 409 的现行为不动" — option 3 over option 2, exactly as implemented.
- Blocker 1 RESOLVED at current head: the `Lint & Repo Gates` check run on the 16:14:31Z suite for head `c6ae648b17` is `"status":"completed","conclusion":"success"`; all 39 check runs at head are completed (38 success, `Console Pin Gate` skipped), none in_progress, none failing. The census fix in the diff is a pure one-line re-anchor in `content/docs/permissions/system-context.mdx` row 21: `metadata-protocol/src/protocol.ts:1576` → `:1664`, same `readonly strip bypassed — INSERT (protocol ingress)` row.
- Blocker 2 RESOLVED: `content/docs/references/api/protocol.mdx` in the diff now appends "The quoted-empty entity-tag (`\"\"`) is refused 400 VALIDATION_FAILED, not treated as omitted." to both `expectedVersion` rows (update-request and delete-request tables), landed at the generated page's true source (the zod `.describe()`) rather than hand-edited.
- Changeset honest, read from the file not the body: frontmatter grades `"@objectstack/metadata-protocol": minor` and the body's banner says "**BREAKING** accept-set narrowing at the guarded-write door, shipped as `minor` under the repo's launch-window convention" — grade and banner agree, and the convention is real and precedented ("Strict-semver breaking, shipped in a minor under the launch-window policy", `content/docs/releases/v15.mdx`; the same phrase family appears in peer changesets such as `.changeset/driver-memory-field-level-uniqueness.md`). The `<!-- adr-0087: not-required (no-migration-prescription) … -->` marker matches the check script's documented disposition vocabulary (`scripts/check-adr-0087-registration.mjs` line "//   <!-- adr-0087: not-required (no-migration-prescription) <why> -->") and is factually right — nothing in the diff renames, retires, or converts any metadata key, spec symbol, or stored value. `'VALIDATION_FAILED',` is already present inside the `'@objectstack/metadata-protocol': [` section of `packages/spec/src/api/error-code-ledger.zod.ts` (alongside `'CONCURRENT_UPDATE',`), so "needs no new ADR-0112 ledger entry" checks out.
- `scripts/engine-double-contract.pinned.json`: the three added rows all cite `"file": "packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts"` with `"pinned": 1` for verbs delete/findOne/update — 15 additions, zero deletions, no existing row's count lowered. Per the ratchet's own header ("pinned to the real engine's dispatch contract"; `--write` is "the repo's existing ratchet idiom"), this is the mechanical registration of a new test file's engine double, not a loosening.
- Blast radius MEASURED in the sibling repo (`/home/user/objectui` at `ba55fb5014`): `occSave.tsx` line "return typeof v === 'string' && v.length > 0 ? v : undefined;" with caller "ifMatch ? { ifMatch } : undefined"; `InlineEditSaveBar.tsx` "typeof data?.updated_at === 'string' ? (data.updated_at as string) : undefined" gated by the same "ifMatch ? { ifMatch } : undefined"; and the adapter's "if (options.ifMatch) headers['If-Match'] = options.ifMatch;" (found at metadata-client.ts:945/:1211 in my checkout vs the PR's cited :874/:1142 — line drift, same content string) which forwards unquoted. The Console cannot produce the `"…"` shape; the PR's claim holds.
- Machine spelling ABSENT — recorded, not fixed: the card's claim comment (5479599156) says "⚠️ **Clause ②: FIRES on the CONTENT limb, unconditionally.**" — neither `Clause-②: yes` nor `Clause-②: no` appears verbatim anywhere on the card. The gate sources confirm both the spelling ("恰这两种拼写:`Clause-②: yes` / `Clause-②: no`", `.claude/skills/pm-dispatch/SKILL.md` line 534) and the read location ("card's claim comment declares `Clause-②: yes`", `scripts/pm/ensure-pm-labels.sh` line 250). Mitigating: the enqueue gate takes the actual diff first ("翻 ready / 入队前先取 PR **实际 diff** —— diff 是事实") and the path limb fires from the diff itself, so no silent gap results in this instance — but the declaration limb's machine carrier is missing and the PM seat should add the line to the claim comment.
- Non-blocking asymmetry, measured at the PR head ref itself: `packages/metadata-protocol/src/index.ts` at `refs/pull/13870/head` is a named export list with no `export *`, exports `ConcurrentUpdateError` ("export { ObjectStackProtocolImplementation, ConcurrentUpdateError, … } from './protocol.js'") but does NOT name `MalformedVersionTokenError` — so the new class, though `export`ed from `protocol.ts`, is unreachable from the published `@objectstack/metadata-protocol` export path (`package.json` exports only `.` → `dist/index`). The wire promise is unaffected (the error's own `code`/`status` ride REST's `classifyDataError`, whose file documents "`VALIDATION_FAILED` — the per-field 400 envelope"), but an in-process consumer can `instanceof`-match the 409 class and not the new 400 class; worth a deliberate decision on a follow-up, not a blocker.
- Scope and bans clean: the full file list is exactly 9 — `.changeset/occ-empty-etag-rejected-at-ingress.md`, `content/docs/api/wire-format.mdx`, `content/docs/permissions/system-context.mdx`, `content/docs/references/api/protocol.mdx`, `packages/metadata-protocol/src/protocol.occ-empty-etag-rejected.test.ts` (added), `packages/metadata-protocol/src/protocol.occ-version-token-instant.test.ts`, `packages/metadata-protocol/src/protocol.ts`, `packages/spec/src/api/protocol.zod.ts`, `scripts/engine-double-contract.pinned.json` — and nothing under `content/docs/releases/` is touched. Zero-hit controls for this review's greps: the `export \*` zero in index.ts sat beside 17 `export {` hits in the same file; the "launch-window" zero in AGENTS.md sat beside hits in `.changeset/` and `content/docs/releases/` in the same query.

Carrier action

needs:contract-review cleared from both carriers in this same stroke — this PR and card #13576. Provenance: maintainer ruling 2026-08-25 ("审核通过你应该直接负责合并吧,还要等原始的项目经理吗"), under which a PASS clears the gate label rather than parking it for a second opinion.

⚠️ The landing action is not mine and I am not taking it. Contract review belongs to the card's dispatching seat (domain:engine here); the triage round is explicitly "非放行必要条件,在线时作事后审计/抽查" and "⛔ 不与席内复核抢同一载体的放行动作". Pre-landing checks → flip ready → enqueue is the dispatching seat's sequence to run. ⛔ Not flipping ready, not arming auto-merge, not approving.

Two follow-ups, neither blocking

  1. MalformedVersionTokenError is not on the published export path. packages/metadata-protocol/src/index.ts names ConcurrentUpdateError but not the new class, and package.json exports only .dist/index — so an in-process consumer can instanceof-match the 409 class and not the new 400 one. The wire promise is unaffected (code/status ride REST's classifyDataError), so this is an asymmetry to decide deliberately, not a defect to block on. Adding it, or documenting why it stays internal, are both defensible — pick one on purpose.
  2. Add Clause-②: yes verbatim to the claim comment on [finding] If-Match: &quot;&quot; silently DISABLES optimistic concurrency — a quoted-empty entity-tag is read as &quot;no token&quot; and the guarded write proceeds unguarded #13576. It currently reads "⚠️ Clause ②: FIRES on the CONTENT limb, unconditionally." — right answer, wrong form. ⚠️ Nothing was lost here: the path limb fires from the diff itself and the enqueue gate reads the actual diff first. Filed systemically as The Clause-②: yes | no machine spelling is missing from the claim comment on 2 of 3 measured cards — the enqueue gate's predicate reads it there, and it is not there #13914.

What the re-review confirmed beyond the two blockers

Given a fresh context and told to re-judge everything, it independently confirmed the maintainer ruling exists and says what the PR claims (comment 5479458952, 决裁批 #20 ①), that the TOKENS carve-out is ruling-authorized rather than evidence-deletion — with the deletion arithmetic accounted line by line — that token coverage went up, that the changeset grade and banner agree, that VALIDATION_FAILED is already ledgered, that the ratchet JSON gained rows without lowering any, and that the blast-radius claim holds measured in the objectui checkout, not accepted from the body.


Generated by Claude Code

zhuangjianguo added a commit that referenced this pull request Aug 31, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

3 participants