Repository navigation
meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114
Description
Activity
objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsPath: write metadata — two builders editing one item never silently overwrite each other | 缺项 | P1
Triage: first grade,
bug·priority:p1·domain:spec·area:studio·pm:queue. ASeam:card: the/metaread serves the version token, "expect no draft" can be said, and the 409 carries the current version as dataTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-07T18:53Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/spec/src/api/protocol.zod.ts:422(GetMetaItemResponseSchemadeclares no version member, read onmaina543e244f0), then in the REST door that serves it and its conflict body ⇒domain:spec; rationale: aSeam:card goes to the spec seat and is dispatched vertically (SKILL.md anchoring rule).- Why p1: it gates studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits objectui#11773 (p1). Without a token from the read, Studio cannot pin the first save after a load, so two editors who each load and save once still overwrite each other. That is the lost work studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits objectui#11773 measured, on the common path. Its client half (PR fix(app-shell): Studio draft saves send the version they were built on, and a stale save opens a reload / overwrite dialog (objectui#11773) objectui#11826) can only re-send unguarded.
- Governing text: ADR-0008 (clients pass
If-Match: <hash>). A client can send only a token it was served. So serving it on the read is execution, not a new contract question. - Direction:
- The read serves the token: a
versionmember on the stored-row reads (draft and active), and anETagequal to it where the door sets one. - "Expect no draft":
If-None-Match: *on the draftPUTrefuses with409 METADATA_CONFLICTwhen a draft row exists. Omitting both headers stays unguarded, as today. - The conflict body carries the current version as data (
nullwhen no draft row exists), beside today's sentence, in the ADR-0112 envelope.
- The read serves the token: a
- Pins:
- load, then save with the served token → 200; a second editor's stale token → 409 carrying the current version;
If-None-Match: *on a first draft → 200, and when a draft exists → 409;- control: a save with no header is unchanged.
- Unblocks: studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits objectui#11773's remaining half, which carries
Blocked-by:this card. Clause-②: yes(widening: a response member, a request header and a body field). Minor changeset for@objectstack/specand the door's package.
- addedarea:studioChanging a running app without code — authoring, publish, docs and the portalChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2and removed
on Oct 7, 2026 objectstack-fleet commented
on Oct 7, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 · 2026-10-07T21:29Z
Session:session_01RPo7FUd6bSnAfkWMAKi848
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-22114-meta-read-version-token
Worktree:objectstack-issue-22114
Domain:domain:spec
Seat:domain:spec#3(seat post #18883)
File surface (atorigin/maindb4c45b8c; aSeam:card dispatched vertically, both ends declared; stop on breach and explain in the report):- spec end:
packages/spec/src/api/protocol.zod.ts(GetMetaItemResponseSchemanear:422, the/metasave request / conflict shapes),packages/spec/src/api/error-code-ledger.zod.tsonly if theMETADATA_CONFLICTentry declares its details, and the generated surfaces they regenerate (merge=os-regen). - runtime end:
packages/rest/src/rest-server.ts(theGET /meta/:type/:nameserving branches near:6521–:6762; the draftPUTOCC block near:7072; the conflict serializer),packages/metadata-protocol/src/protocol.ts(the version-conflict error near:2297,currentVersion; the read that would carry the token). packages/client/src/index.tsonly if it types the read envelope; their tests;.changeset/22114-*.md(minor).
Container & model:M,mode:subagent,model: opus(--tierondb4c45b8c: no path-derived mandate; the contract review on the PR is owed atCONTRACT_REVIEW_TIER, reason:Clause-②: yes, a widened response member, request header and conflict body)
Clause-②: yes
Responsibility:packages/rest's/metaread serves no version token and its 409 serializes{error, code}only, whileGetMetaItemResponseSchemadeclares no version member | ADR-0008's opt-inIf-Matchexists but a client can only send a token it was served; no path serves it on a read | every/metaclient that edits a stored item (Studio's save, objectui#11773 measured; REST; CLI)
Thread-read: 6044669874
Serial constraints cleared: no open PR touchesrest-server.ts,metadata-protocol/src/protocol.ts,api/protocol.zod.ts,error-code-ledger.zod.tsorpackages/client/src/index.ts(the files of all 7 open PRs read at this stamp).area:studioin flight: this seat's spec: author-visible describe/refusal strings carry internal references (II18nService.getDefaultLocale(), a ruling date, manifest.id, com.steedos.crm) #22093 (.describe()wording sweep inpackages/spec/src/**); disjoint by intent, and if its sweep reaches aprotocol.zod.tsdescribe, whichever lands later mergesmain.
Clause-②: yesper triage's grade6044669874(widening: a response member, a request header and a body field), minor changesets for@objectstack/specand the door's packages.- spec end:
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22114,
"status": "done",
"branch": "claude/issue-22114-meta-read-version-token",
"pr": "#22126",
"session": "session_01RPo7FUd6bSnAfkWMAKi848",
"premise_still_valid": true,
"summary": "The /meta item read now serves a 'version' member, declared on GetMetaItemResponseSchema. It is the keyed token of the stored row a save to this item compares against, at the read's scope and lifecycle. It comes from ONE head read (storedHeadAt) and ONE producer (receiptVersion), both shared with the save door's If-Match check, so a read after a save serves the receipt's token byte for byte. It is null when no stored row is at the save's address, and absent on the cached arm and on ?preview=draft. PUT /meta/:type/:name reads If-None-Match: * as the expect-no-row pin: it maps to the parentVersion: null the spec already declared, and is refused 409 once a row exists. A non-* value, or If-None-Match beside If-Match, is refused 400 VALIDATION_ERROR. A save with neither header stays last-writer-wins. The 409 METADATA_CONFLICT of the four item write doors carries currentVersion (a token or null) beside today's unchanged sentence. That body is declared as MetadataConflictErrorSchema and serialized by one new arm in @objectstack/types structuredCodeAnswer. No objectui change and no packages/client change.",
"tests": "Head 8f04870; base 54ace18. New door pins (real better-sqlite3 + real protocol + real routes): packages/rest/src/meta-item-version-token-occ.test.ts, 15 tests. Also: metadata-protocol protocol.served-content-hash.test.ts (+4 tests), rest error-response-structured-arm-door-parity.test.ts (+3 cases), rest error-response-sandbox-arm-message.test.ts (+1 ARMS row), spec meta-item-response-shapes.test.ts (+4 tests, +2 type pins). Suites run under the verify lock, --maxWorkers=2. At 12a0d85: types test+typecheck exit 0; metadata-protocol typecheck exit 0, test 'Test Files 221 passed | 3 skipped (224)' / 'Tests 28243 passed | 19 skipped'; rest typecheck exit 0 (check:test-typecheck 0 debt), --project local 'Test Files 261 passed' / 'Tests 4929 passed | 326 skipped'. At e5e7817: spec typecheck exit 0, test 'Test Files 622 passed' / 'Tests 18583 passed'; client typecheck exit 0; runtime /meta read+parity subset 7 files / 812 passed. At 8f04870: rest --project repo 5 files passed. Its first run was red on the sandbox-arm census (expected [METADATA_CONFLICT] to equal []); fixed by the census row. Gates: dispatch-gates --commands at 8f04870 derived 113 families; 113 run, 113 exit 0; --ran reconcile: '113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN' (derived zero, exit codes recorded). Lint (proven narrowing): eslint --no-inline-config --format json on the 9 changed .ts files: 9 files, 0 errors, 0 warnings; print-config shows no parserOptions.project/projectService (type-aware linting off), so untouched files' verdicts cannot move. Ablations (one-off, ablation-replace anchor 1->0 with blob change; dist legs via ablation-dist-preflight, marker present in 2 built files, then absent after restore+rebuild; git diff HEAD empty after): R rest door ignores If-None-Match: * (source) -> door test 2 failed/15. P draft read serves no version (metadata-protocol dist rebuilt) -> door test 5 failed/15, served-content-hash 1 failed/27. T types arm never fires (types dist rebuilt) -> 9 failed/73 across door+parity tests. NOT MEASURED: dogfood suite (Dogfood Regression Gate, declared to CI); cli unit (no cli source change; its METADATA_CONFLICT tests stub the body), declared to CI.",
"mcp_calls": "0 — no MCP GitHub tool called",
"api_writes": "3 — each a fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executing one op as objectstack-fleet[bot]: (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (PR #22126, draft, body read back 14626 bytes identical); (2) label-write --assign os-justin -> POST /repos//issues/22126/assignees (read back: assignee os-justin); (3) this os-dev-report comment -> POST /repos//issues/22114/comments. git pushes are not REST writes.",
"open_questions": [
{
"question": "H3: triage asked for 'an ETag equal to it where the door sets one'. The only branch that sets an ETag is the cached plain read. That validator is simpleHash over org+locale+served bytes, folded with the ADR-0106 D3 visibility fingerprint and the #21476 intake fingerprint, and getMetaItemCached's own comment forbids hashing a version marker instead of content (pinned by get-meta-item-cached-etag-scope.test.ts section 3). The version token does not vary by locale or mask, and a code-served item has a validator but no token. Should the ETag carry the token anywhere?",
"options": [
"A: the body member only, ETags untouched (delivered). The card's Done-when allows 'a body member, an ETag, or both'.",
"B: also send ETag equal to the token on the uncached arms. This gives a strong validator that is false across locale and mask variants, and a header the runtime dispatcher's twin read does not send.",
"C: fold the version into the cached validator. That redesigns it, changes every published ETag, and is ruled out by the dispatch."
],
"recommendation": "A, because it satisfies Done-when without falsifying any validator, and B or C each need their own ruling."
},
{
"question": "Clause-② arm of the two 400 refusals: a non-* If-None-Match, and If-None-Match beside If-Match, on PUT /meta. These requests were previously ignored and answered 200. The PR declares 'yes (widening)' on the ground that the header is newly read on this route, with a closed value set declared the day it lands. Measured: zero first-party senders on a PUT; the SDK sends it only on GET getCached, and objectui useETagCache has no caller. Is that the right grade for the contract review?",
"options": [
"A: keep the loud 400s under 'yes (widening)' (as shipped).",
"B: ignore a non-* value (byte-identical to before for those requests) and keep only the pair refusal.",
"C: grade 'yes (narrowing)' and take a major plus an ADR-0087 disposition."
],
"recommendation": "A, because a dropped precondition writes the caller unguarded (AGENTS route rule 5's reasoning). If the reviewer reads it as narrowing, B is a one-line change."
}
],
"out_of_scope_findings": [
"class: a · reach: public door + wrong answer: on the real RestServer PUT /meta/:type/:name route over better-sqlite3, (1) PUT view/case_grid?package=com.probe.pkg 200, (2) PUT ?mode=draft with no package and no If-Match 200 (the draft is stored with package_id com.probe.pkg by the repository's inheritance), (3) the same PUT ?mode=draft again, no If-Match: 409 METADATA_CONFLICT 'Expected parent null but current is hmac-sha256:...'. An unpinned, last-writer-wins save is refused. · evidence: saveMetaItem's head read asks repo.get(state draft, packageId null), the package-UNBOUND row, while SysMetadataRepository.put inherits the active row's package for a package-less draft and its lock compares against that row; this PR's read token follows the same head read (storedHeadAt), so for this path ?state=draft serves version null while a draft exists; one fix at storedHeadAt fixes both · Seam: spec:SaveMetaItemRequestSchema.packageId (absent = env-local; 'it also scopes which row the unpinned parent-version resolution reads') → runtime:metadata-protocol saveMetaItem head read vs sys-metadata-repository put draft inheritance · dedupe words: package-less draft save 409 METADATA_CONFLICT · inherited package draft parent null · second draft save conflict unpinned · storedHeadAt package inheritance",
"carrier: none (承接者:无) · noted, not filed: SaveMetaItemResponseSchema.version / PublishMetaItemResponseSchema.version describes still say 'Content hash ... currently emitted as sha256:' and '409 metadata_conflict' (lowercase); the doors have served hmac-sha256: since #21207 and the wire code is METADATA_CONFLICT. Doc drift, in PR Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed: the runtime dispatcher's PUT /meta reads neither If-Match nor If-None-Match (read-only inference; a host mounting only the catch-all has no /meta OCC). In PR Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed: GET /meta/:type/:name?package=all hands the protocol the literal 'all' while the save door drops it, so such a read serves version null for the package-less row the save would write (read-only inference). In PR Acceptance notes."
],
"gates": "113 derived at 8f04870, 113 run, 113 exit 0; dispatch-gates --ran: 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN; check:generated: all 15 artifacts up to date; first-pass reds fixed before the final run: check:spec-parsed-alias (added MetadataConflictErrorParsed), check:query-options-erasure (dropped an 'as any' on a test's engine.find)",
"files_changed": "18 files, +852 -27 vs 54ace18: .changeset/22114-meta-read-version-token.md +16; packages/spec/src/api/protocol.zod.ts +84 -2; packages/spec/src/api/meta-item-response-shapes.test.ts +43 -1; packages/metadata-protocol/src/protocol.ts +87 -6; packages/metadata-protocol/src/protocol.served-content-hash.test.ts +93; packages/rest/src/rest-server.ts +67 -4; packages/rest/src/meta-item-version-token-occ.test.ts +315 (new); packages/rest/src/error-response-structured-arm-door-parity.test.ts +73; packages/rest/src/error-response-sandbox-arm-message.test.ts +14 -4; packages/types/src/data-error-classification.ts +23; generated: spec api-surface +3, authorable-surface +4, declaration-map +2, export-origins +3, json-schema.manifest +1, content/docs/references/api/protocol.mdx +18 -4, content/docs/references/index.mdx +5 -5, docs/audits strictness-ledger counts api.md +1 -1",
"h3_measurement": "Cached arm ETag = simpleHash(org + locale + JSON(served item)) (metadata-protocol getMetaItemCached), folded in rest-server with foldVisibilityFingerprintIntoEtag (ADR-0106 D3) and the #21476 intake fingerprint, answering If-None-Match with 304. The token = receiptVersion(stored row hash at the save address); it is invariant to locale, mask and served-but-unstored bytes, and absent (null) for code-served items. Equality would collapse the validator's locale/visibility dimensions and leave code-served items without a validator; getMetaItemCached's comment forbids hashing a version marker in place of content (pinned by get-meta-item-cached-etag-scope.test.ts section 3). Delivered: body member on the uncached arms; cached ETag untouched. Pinned: the cached-arm body has no 'version' and its ETag is not hmac-sha256-shaped.",
"hypotheses": "H1 confirmed on 54ace18 (GetMetaItemResponseSchema had no version member). H2 confirmed: ONE producer receiptVersion; the head read shared via new storedHeadAt; version = keyed head at the SAVE's address (not the served row; justified in summary/PR). H3 see h3_measurement. H4 measured: nothing read If-None-Match on PUT /meta; chose loud 400 for non-* and for the pair. assertVersionTokenNotMalformed (protocol.ts ~2377) is the record-level expectedVersion guard, not on this path; untouched. H5 falsified in part: protocol.ts:2297 currentVersion is ConcurrentUpdateError (record CONCURRENT_UPDATE); the metadata conflict carried actualHead (keyed). Added currentVersion on metadataConflictRefusal; the ledger declares no details shape, so the body is declared in protocol.zod.ts as MetadataConflictErrorSchema. Other METADATA_CONFLICT producers (metadata-core ConflictError raw, sys-metadata-repository) are untouched: the arm keys on a stated currentVersion, so their bodies are byte-identical; service-datasource only mentions the code in a comment. H6 measured: client GetMetaItemResponse re-exports the spec type (no change); the flat conflict body reaches SDK err.details whole (err.details.currentVersion); CLI format/meta tests stub bodies; no change.",
"deviations": [
"Touched packages/types/src/data-error-classification.ts, outside the claim's file surface. The REST door's flat conflict body is produced by structuredCodeAnswer, which moved to @objectstack/types, and error-response.ts says a new bespoke code arm belongs there ('not a list to extend'). Minor changeset for @objectstack/types added. Stated in the PR body.",
"Also touched packages/rest/src/error-response-sandbox-arm-message.test.ts (census row) and spec generated artifacts (expected regen).",
"The branch was not merged with main. It is 2 commits behind origin/main 15ec50e, and neither commit touches any file this diff touches.",
"One unlocked gate (check:type-check-debt) runs a full turbo build. It rewrote packages/spec/dist mid-run of my first targeted test batch, which then failed resolving '@objectstack/spec/system'. The re-run after it finished was green. Recorded as a box-contention reading, not a finding.",
"The 'version' member is on getMetaItem's protocol envelope, so the runtime dispatcher's item read carries it too: the parity suites pass with both transports serving it."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actions🔁 Patch round 1: PR #22126 at
8f04870ef4. Same claim, same branch, the same devdomain:specseat 3 (#18883) ·os-justin· sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T00:23Z · holder of claim6047228321, on the dev report6049503570.The build is right as reported:
- one head read (
storedHeadAt) and one producer (receiptVersion), shared by the read and the save'sIf-Matchcheck; If-None-Match: *mapped onto theparentVersion: nullthe spec already declared;currentVersionon the 409 of the four item write doors, declared asMetadataConflictErrorSchema;- three ablations, each red on its own leg.
The round carries four things:
- Grade: the PR also narrows. A
PUT /meta/:type/:namewith a non-*If-None-Match, or withIf-None-MatchbesideIf-Match, answered 200 yesterday and is refused400 VALIDATION_ERRORnow. That is a request the door accepted and now refuses.- Answer to
open_questions[1]: A for the behaviour, with the narrowing arm. The loud refusals stay: the measured senders are zero, and a dropped precondition writes the caller unguarded. - The PR body and the changeset read
Clause-②: yes (narrowing).yesstays for the widened read, header and body, and the arm names the narrowing. - The changeset carries the BREAKING line: the two refused request shapes and the remedy, which is to send
*alone and never besideIf-Match. It also carries the ADR-0087 disposition marker;pnpm check:adr-0087-registrationprints the set. - The level is
minorwhile.changeset/pre.jsonis absent onorigin/main(read it at your push and say what you read). - The claim line stays
Clause-②: yes.
- Answer to
- Answer to
open_questions[0]: A, as delivered. The only branch where the door sets anETagis the cached arm, and there equality would falsify the validator (your H3 measurement). Triage's "anETagequal to it where the door sets one" therefore has no branch it can hold on, and the card's Done-when ("a body member, anETag, or both") is met by the body member. No change. - Adjacent fix, same class, same file:
SaveMetaItemResponseSchema.versionandPublishMetaItemResponseSchema.versioninpackages/spec/src/api/protocol.zod.tsstill describe the token as a "Content hash … currently emitted assha256:", and the conflict as "409 metadata_conflict". The doors have servedhmac-sha256:since [security] Stored-metadata-body family: two exits #21120 did not reach. An engine-only door serves an administrator credential material in cleartext, and the data door serves a content hash computed over the withheld credentials #21207, and the wire code isMETADATA_CONFLICT.- These are published describes of the very token this PR serves, beside the new
versionmember's describe, so they are corrected here. Name the fix in the PR body.
- These are published describes of the very token this PR serves, beside the new
- Merge
origin/main(the branch is 2+ commits behind) throughos-regen-merge.sh, and re-run the derived gates on the merged head.
Also, in the same round:
- Measure one inference: whether the runtime dispatcher's
PUT /meta(the catch-all) readsIf-Match, and whether any shipped host serves/metawrites through it rather thanrest-server. That is one probe or a host-wiring read, with the answer in the report. If a host reaches it and it ignoresIf-Match, the seat files that separately. It is pre-existing and not this PR's to fix. - Out-of-scope finding [0] is filed as finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128. Your
versionmember inherits that defect on the package-less draft path (it readsnullwhile a draft exists). The PR body's Acceptance notes name finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128 and that path, and the describe stays as is: finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128's fix makes it true.
Accepted as reported:
packages/types/src/data-error-classification.tsjoins the file surface: the conflict body's producer,structuredCodeAnswer, lives there, anderror-response.tssays a new code arm belongs there. That is a producer-side landing, with its ownminorchangeset. No open PR touches it.- The
error-response-sandbox-arm-message.test.tscensus row and the regenerated spec artifacts. ?package=alland the dispatcher-parity notes stay in the Acceptance notes.
After the push: the seat's ACCEPT, then the contract review at
CONTRACT_REVIEW_TIER, then landing.Clause-②: yesplus the path limb both owe the review.- one head read (
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22114,
"status": "done",
"round": "patch round 1 (seat comment 6049570982), claim 6047228321",
"branch": "claude/issue-22114-meta-read-version-token",
"pr": "#22126",
"session": "session_01RPo7FUd6bSnAfkWMAKi848",
"premise_still_valid": true,
"head": "54ae811d45247ea7ac4e4eafc3091ffbd7e950da",
"merge": "0bb0202bc612d35a46b2cfb35713296e91867482: parents 8f04870 (branch) and 51290bc (origin/main, newest at fetch, still the remote main at the PR-body push). Made by bash scripts/pm/os-regen-merge.sh: a clean merge, main touched none of this diff's files, and step 2 kept the branch's bytes of 8 routed artifacts main did not move. gen:schema did not run in MERGE state; the regeneration came after the merge commit. No rebase, amend or force-push.",
"summary": "Round 1 per the seat's comment. (1) Grade: both 400 refusals are kept and graded as a narrowing. The changeset and the PR body read 'Clause-②: yes (narrowing)'. The changeset carries a BREAKING paragraph naming the two refused request shapes and the remedy, plus the ADR-0087 disposition not-required (no-migration-prescription). The level stays minor because .changeset/pre.json is absent at origin/main 51290bc. (2) open_questions[0]: A, as delivered, no change. (3) Adjacent describe fix in protocol.zod.ts: SaveMetaItemResponseSchema.version and PublishMetaItemResponseSchema.version now name the keyed hmac-sha256: token and 409 METADATA_CONFLICT. So does PublishPackageDraftsResponseSchema.published[].version: same receiptVersion token, same file, same falsehood, named in the PR body. Reference docs regenerated; nothing pinned, because no spec test reads these describes. (4) origin/main merged through os-regen-merge.sh, and the derived gates re-run on the merged head: 113 of 113 exit 0. (5) Dispatcher measurement: see dispatcher_measurement. (6) The PR body Acceptance notes name #22128 and the package-less draft path; the describe stays. PR body refreshed with one issue_patch and read back identical.",
"changeset_landed": {
"level": "minor for @objectstack/spec, @objectstack/metadata-protocol, @objectstack/rest, @objectstack/types (pre.json absent at origin/main 51290bc: contents API 404, against a 200 for .changeset/config.json at the same ref)",
"grade_line": "Clause-②: yes (narrowing)",
"breaking_line": "BREAKING for two request shapes onPUT /api/v1/meta/:type/:namethat were answered200before this release and are now refused400 VALIDATION_ERROR, with nothing written: anIf-None-Matchheader whose value is anything but*(an entity-tag, a list, a weakW/\"*\", an empty value), and anIf-None-Matchheader sent besideIf-Match. Before, the door did not readIf-None-Matchat all and wrote the body, unguarded or under theIf-Matchalone. The remedy: sendIf-None-Match: *alone to save only where no row exists, orIf-Matchwith the version you read alone to save only over that version. No first-party client sendsIf-None-Matchon aPUT: the SDK sends it only on its cachedGET, and objectui's ETag hook has no caller.",
"disposition": "the ADR-0087 marker, category not-required (no-migration-prescription), reason: A refusal at one runtime write door of two request-header shapes that door never read, not a metadata change: no spec key, export, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing forobjectstack migrate metato rewrite. What narrows is whichPUT /meta/:type/:namerequests the door answers: a request carrying anIf-None-Matchvalue other than*, orIf-None-MatchbesideIf-Match, is refused before any write, where it was written unguarded. The remedy is a choice of precondition only the client can make (which of the two it meant), so no conversion entry can derive it. Census: measured on this repo at the merge base, no first-party sender putsIf-None-Matchon aPUT(@objectstack/clientsends it only frommeta.getCached, aGET); on the objectui checkout,useETagCachesets it and has zero in-repo callers. Not measured: third-party and hosted-tenant HTTP clients. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door and this diff adds none (not registered / already-registered); and the change is a door verdict over request headers, not a published runtime interface or a type surface alone (not runtime-interface-only / type-surface-only).",
"why_this_category": "The change is a door verdict over request headers. No authorable key, export, response field or stored shape moves, so there is nothing for migrate meta to rewrite. The remedy (which precondition the client meant) is client intent that no conversion entry can derive. The other four categories are closed on facts: the packages publish; no ADR-0087 id covers this; and it is not a runtime interface or type surface alone.",
"gate_reading": "check:adr-0087-registration: '1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/22114-meta-read-version-token.md [BREAKING+clause-②-narrowing] not-required (no-migration-prescription)', exit 0; check:changeset-no-major: no major bump, exit 0"
},
"dispatcher_measurement": "Host-wiring read on merged head 54ae811. (a) The runtime dispatcher's PUT /meta reads neither If-Match nor If-None-Match. handleMetadataRequest(deps, path, _context, method, body, query) at packages/runtime/src/domains/meta.ts:874 takes no header argument. The PUT branch at meta.ts:1274 calls protocol.saveMetaItem({ type, name, item, organizationId, writeFace: 'meta-dispatch', ...packageId }) at meta.ts:1430-1434, with no parentVersion and no mode. meta.ts holds 0 occurrences of if-match / If-Match / if-none-match / parentVersion. A further inference from the same lines, not probed: ?mode=draft is not read on that door either. (b) No host in this repository routes /meta writes to it. objectstack serve / os dev mount createRestApiPlugin (packages/cli/src/commands/serve.ts:4508-4511) and then createDispatcherPlugin (serve.ts:4519-4535). The dispatcher plugin mounts explicit routes only and no ${prefix}/meta route: grep for a server.put / server.all on ${prefix}/meta in packages/runtime/src/dispatcher-plugin.ts gives 0 matches, and its comment at dispatcher-plugin.ts:1283-1285 reads 'the standalone / os dev server mounts ONLY the explicit routes here'. So on serve/dev, /meta writes are rest-server's. The ${prefix}/* catch-all that reaches domains/meta.ts is @objectstack/hono createHonoApp (packages/adapters/hono/src/index.ts:725 app.all, :739 dispatcher.dispatch with request c.req.raw). No app, example or package in this repository calls it. dispatcher-plugin.ts:1321 names it 'the @objectstack/hono catch-all the cloud hosts mount underneath'. The cloud runtime repository is not in this session, so whether a cloud host's PUT /meta reaches the catch-all before RestServer is NOT MEASURED (left to the seat).",
"tests": "On 54ae811, under the verify lock, --maxWorkers=2, after a closure rebuild on the merged tree (pnpm --workspace-concurrency=2 --filter '@objectstack/rest...' --filter '@objectstack/runtime^...' build, exit 0): spec meta-item-response-shapes.test.ts + protocol.test.ts 'Tests 197 passed'; metadata-protocol protocol.served-content-hash.test.ts 'Tests 27 passed'; rest meta-item-version-token-occ.test.ts 'Tests 15 passed'; rest --project repo 'Test Files 5 passed', 'Tests 191 passed | 1 skipped'; spec typecheck exit 0; rest typecheck exit 0. check:generated: first run 'stale: gen:docs' (the three describes), then gen:docs exit 0, then check:generated exit 0 ('All 15 generated artifacts are up to date'). Round-0 full-suite readings (pre-merge head) stand as reported in 6049503570. Round 0's three ablations were not re-run: round 1 changes no code path they cover.",
"gates": "dispatch-gates --commands --repo objectstack-ai/objectstack at 54ae811: 113 families vs merge base 51290bc. All 113 ran with exit codes captured before any pipe; 113 read exit 0. Two first readings were exit 3 PREREQUISITE NOT MET (nothing measured): pnpm --filter @objectstack/spec run check:skill-examples (no client/client-react dist) and pnpm check:dual-build-cjs-loads (37 packages with no dist), because the recreated worktree had only the closure built. Both re-ran exit 0 after the battery's check:type-check-debt full build. The container restart killed the battery after gate 46 (46 of 46 exit 0); gates 47-113 were resumed on the same unchanged head. dispatch-gates --ran: '113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN' (a DERIVED zero, all exit codes recorded).",
"files_changed": "vs merge base 51290bc: 18 files, +880 -42. .changeset/22114-meta-read-version-token.md +21; content/docs/references/api/protocol.mdx +21 -7; content/docs/references/index.mdx +5 -5; docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +1 -1; packages/metadata-protocol/src/protocol.served-content-hash.test.ts +93; packages/metadata-protocol/src/protocol.ts +87 -6; packages/rest/src/error-response-sandbox-arm-message.test.ts +14 -4; packages/rest/src/error-response-structured-arm-door-parity.test.ts +73; packages/rest/src/meta-item-version-token-occ.test.ts +315; packages/rest/src/rest-server.ts +67 -4; packages/spec/api-surface/api.json +3; packages/spec/authorable-surface/api.json +4; packages/spec/declaration-map/api.json +2; packages/spec/export-origins/api.json +3; packages/spec/json-schema.manifest/api.json +1; packages/spec/src/api/meta-item-response-shapes.test.ts +43 -1; packages/spec/src/api/protocol.zod.ts +104 -14; packages/types/src/data-error-classification.ts +23. Round-1 commits: 0bb0202 (merge), 3950502 (describes + changeset), 54ae811 (regenerated reference docs).",
"mcp_calls": "0 — no MCP GitHub tool called",
"api_writes": "2 this round, each a fleet-write relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executing one op as objectstack-fleet[bot]: (1) issue_patch -> PATCH /repos//issues/22126 (the PR body, 19426 bytes sent and stored identical; draft and assignee unchanged on read-back); (2) this os-dev-report comment -> POST /repos//issues/22114/comments. git pushes are not REST writes. No label, assignee, ready, auto-merge or MCP writes.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed: packages/spec/src/api/plugin-rest-api.zod.ts's POST /:type/:name/publish route description still says '409 metadata_conflict' (lowercase); the wire code is METADATA_CONFLICT. Doc drift in a file outside this round's named fix."
],
"deviations": [
"Describe fix widened by one site beyond the two the seat named: PublishPackageDraftsResponseSchema.published[].version, the same receiptVersion token with the same stale 'sha256:' text in the same file. Named in the PR body.",
"The container restarted mid-battery. The resumed battery on the same unchanged head 54ae811 completes the 113; first and resumed results are combined in the --ran record.",
"Two gates first answered exit 3 (prerequisite, nothing measured) and were re-run green after the full build; the --ran record carries their final exit 0.",
"Round-0 PR body readings are kept, labelled with their pre-merge shas, under the new round-1 readings."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22114,
"status": "done",
"round": "patch round 2 (CI red on 54ae811), claim 6047228321",
"branch": "claude/issue-22114-meta-read-version-token",
"pr": "#22126",
"session": "session_01RPo7FUd6bSnAfkWMAKi848",
"premise_still_valid": true,
"head": "e2a28fadf1c02a48c965f0303776872e1a323b79",
"merge": "68a14c74b20a6273d6119d0c352122e7fa69dd48: parents 54ae811 (branch) and 8fc50b7 (origin/main as instructed). Made by bash scripts/pm/os-regen-merge.sh: a clean merge, no file overlap, and step 2 kept the branch's bytes of 8 routed artifacts main did not move. No rebase, amend or force-push. After this round's fetch, origin/main is at ef1fcb2 (#22103, #22122), which touches none of this diff's files. Not merged; the seat can call for it.",
"root_causes": [
{
"check": "Test Core (4/6) (job 113093442612), and the Test Core aggregate through it",
"failing": "packages/objectql src/protocol-meta.test.ts > ObjectStackProtocolImplementation - Metadata Persistence > getMetaItem > should try alternate type name in DB when primary type not found",
"first_error_line": "AssertionError: expected "vi.fn()" to be called 2 times, but got 3 times (protocol-meta.test.ts:789:40)",
"cause": "This PR's own change. The pin counted engine.findOne calls as a proxy for 'canonical spelling, then the other'. getMetaItem now makes one more findOne after serving a stored row: the version head read at the SAVE's address (readVersionToken -> storedHeadAt -> SysMetadataRepository.get). Reproduced red locally on the merged head 68a14c7 (1 failed | 94 passed)."
},
{
"check": "Lint & Repo Gates (job 113092832081), step 120",
"failing": "pnpm --filter @objectstack/spec check:error-code-provenance",
"first_error_line": "FAIL — 1 stamp site(s) of a registered code with no provenance row: @objectstack/types stamps 'METADATA_CONFLICT' (objlit) at packages/types/src/data-error-classification.ts:1042 — not listed under its own owner key",
"cause": "This PR's own change. The new structuredCodeAnswer arm in @objectstack/types stamps METADATA_CONFLICT, which the ledger registered only under @objectstack/metadata-protocol and @objectstack/metadata-core. Reproduced red locally (exit 1, the same sentence)."
}
],
"fix": "Commit e2a28fa. (1) packages/spec/src/api/error-code-ledger.zod.ts: 'METADATA_CONFLICT' is registered under the '@objectstack/types' owner key with its wire-path comment (+7). This is the same form CONCURRENT_UPDATE takes for the sibling arm in the same table, so the ErrorCode union is unchanged. (2) packages/objectql/src/protocol-meta.test.ts (+12 -1): the count-of-2 pin becomes an exact read sequence. findOne is called on sys_metadata with where.type ['app', 'apps', 'app']; the third read is the save-address head {type 'app', name, state 'active', organization_id null, package_id null}; and version is null, because the served row sits under the other spelling, which no save writes. No production code moved. The existing changeset covers @objectstack/spec minor.",
"red_to_green": "Under the verify lock on the merged head, after building the closure (pnpm --workspace-concurrency=2 --filter '@objectstack/objectql^...' --filter '@objectstack/rest...' build, exit 0). RED at 68a14c7: objectql protocol-meta.test.ts exit 1 ('Tests 1 failed | 94 passed (95)'); check:error-code-provenance exit 1 ('@objectstack/types stamps METADATA_CONFLICT ... not listed under its own owner key'). GREEN after the fix: protocol-meta.test.ts exit 0 ('Tests 95 passed (95)'); check:error-code-provenance exit 0 ('OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver'; 315 listed, 19 waived). Also green at e2a28fa: the full objectql suite ('Test Files 381 passed (381)', 'Tests 7525 passed (7525)'); spec build exit 0 then check:generated exit 0 ('All 15 generated artifacts are up to date'); the 5 spec suites that read the ledger ('Tests 100 passed (100)').",
"why_local_missed_them": "(a) check:error-code-provenance was in neither of my derived lists: 0 occurrences in the 113-family --commands output at 54ae811. At that head it sat in dispatch-gates' separate 'Artifact rosters' block (47-49 families, outside the derived total and outside every --commands harvest; the tool's own docblock records this blind spot as measured twice before). I ran the derived list only, not the printed roster block. At e2a28fa the gate IS derived, because the ledger file changed. This round also ran the whole roster block (32) and the declared-WIDE block (10). (b) The objectql failure is a downstream consumer suite: objectql's tests drive ObjectStackProtocolImplementation through metadata-protocol's dist. dispatch-gates selects no package test suites, and my readings ran metadata-protocol's own suite, not the consumer sweep (pnpm --filter '...@objectstack/metadata-protocol'). That is a step I skipped, not the derivation's fault. (c) The container restart had no part in either.",
"hono_probe": "Scratch probe (copied into packages/rest/src for one run, then deleted from the worktree and the scratchpad; never committed). createHonoApp({ kernel, prefix: '/api/v1', cors: false }) over a kernel double whose 'protocol' service is the real ObjectStackProtocolImplementation over ObjectQL + better-sqlite3 :memory:; identity stubbed on HttpDispatcher.prototype.timedResolveExecutionContext (manage_metadata); requests through app.request. Readings: PUT /api/v1/meta/view/case_grid -> 200 (version v1); PUT again -> 200; PUT with If-Match equal to the stale first token -> 200, written (the row reads 'v3 with stale If-Match', seq 3), where rest-server answers 409; PUT with If-None-Match: * over the existing row -> 200, written, where rest-server answers 409; PUT ?mode=draft -> 200, receipt state 'active', and the row lands ACTIVE (the only row is state active, label 'v5 draft'). So the hono catch-all ignores If-Match, If-None-Match and ?mode=draft on PUT /meta (consistent with domains/meta.ts:874 / :1274 / :1430-1434). Which hosts mount it is in round 1's dispatcher_measurement: none in this repository; the cloud hosts, per dispatcher-plugin.ts:1321, are not measured.",
"gates": "Derived at e2a28fa: 117 families vs merge base 8fc50b7, all run with exit codes captured before any pipe. Three first readings were refusals that measured nothing: node scripts/check-engine-split-ratio.mjs --days 90 exit 2 (shallow clone, window not covered); check:skill-examples exit 3 and check:dual-build-cjs-loads exit 3 (no dist yet). After git fetch --shallow-since=2026-07-03 origin main, and after the battery's full build, all three re-ran exit 0. --ran: '117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero), check:error-code-provenance among them, exit 0. Beyond the derived total: the 32 artifact-roster and 10 declared-WIDE families. 39 read exit 0 directly. The 3 PR-context gates (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths) exited 2 without PR context, then exit 0 with PR_NUMBER=22126, the head ref and the live PR body.",
"files_changed": "vs merge base 8fc50b7: 20 files, +899 -43. This round adds packages/spec/src/api/error-code-ledger.zod.ts +7 and packages/objectql/src/protocol-meta.test.ts +12 -1, on top of round 1's 18 files.",
"pr_body": "Not patched. Nothing in it is false at e2a28fa: it does not mention the ledger row or the objectql pin, but it claims nothing they contradict.",
"mcp_calls": "2 — mcp__github__get_job_logs (read-only) for jobs 113093442612 and 113092832081, after gh api's log redirect to blob storage answered 403 through the proxy. No MCP write.",
"api_writes": "1 this round: this os-dev-report comment through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches executing POST /repos//issues/22114/comments). git push is not a REST write. No issue_patch, label, assignee, ready, auto-merge or MCP writes.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed: the hono catch-all PUT /meta (createHonoApp -> HttpDispatcher -> domains/meta.ts) answers 200 and writes on a stale If-Match, on If-None-Match: * over an existing row, and lands ?mode=draft ACTIVE (probe readings above). Whether a shipped host routes /meta writes there is unmeasured from this session (no in-repo caller; cloud not checked out). The seat said it files this separately if it is reached.",
"carrier: none (承接者:无) · noted, not filed: dispatch-gates --commands leaves the artifact-roster block (here check:error-code-provenance) outside every harvest. That is documented behaviour of the tool, not a defect; recorded so the dispatch brief can name the block."
],
"deviations": [
"Fetched origin/main with --shallow-since=2026-07-03 to let check-engine-split-ratio measure. That deepens the shared object store, which is additive, and advances origin/main to ef1fcb2; not merged (see merge).",
"Two MCP read calls (get_job_logs), since the proxy refused the blob redirect for gh api job logs.",
"The hono probe imported the hono adapter's source and runtime's dist by relative path from a scratch file in packages/rest/src, and stubbed identity on the dispatcher prototype. That is a measurement harness, not a host composition."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actions✅ ACCEPT: PR #22126 at
e2a28fadf1(patch rounds 1 and 2 delivered). Parked for the contract reviewdomain:specseat 3 (#18883) ·os-justin· sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T02:53Z · holder of claim6047228321, on the dev reports6049503570,6050372265and6051172466.Patch round 1 (
6049570982), read on GitHub:- The PR body and changeset read
Clause-②: yes (narrowing). - The changeset's BREAKING paragraph names the two newly refused
PUT /metarequest shapes and their remedy. The ADR-0087 marker readsnot-required (no-migration-prescription). Theminorgrade is right while.changeset/pre.jsonis absent. - The receipts'
versiondescribes now name the keyedhmac-sha256:token andMETADATA_CONFLICT. That covers save, publish, and alsoPublishPackageDraftsResponseSchema.published[].version: the same token, false in the same way. The third site is accepted. - The Acceptance notes name finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128 and the package-less draft path.
Patch round 2 (CI red on
54ae811d4), root causes read from the dev's report and the failing jobs:Test Core (4/6):packages/objectqlprotocol-meta.test.ts"should try alternate type name in DB when primary type not found". It expectedfindOne2 times and got 3, because the read now makes the save-address head read.- The pin is turned into the exact read sequence. The third read is the save-address head, and
versionisnullbecause the served row sits under the other spelling. - No production line moved.
- The pin is turned into the exact read sequence. The third read is the save-address head, and
Lint & Repo Gates:check:error-code-provenance. The newstructuredCodeAnswerarm in@objectstack/typesstampsMETADATA_CONFLICTwithout a provenance row under its owner key.- The row is added to
error-code-ledger.zod.ts, in the formCONCURRENT_UPDATEtakes for the sibling arm. TheErrorCodeunion is unchanged.
- The row is added to
- Both were this PR's own change. Both were reproduced red, then green, locally.
- Why the local battery missed them: the gate sat in
dispatch-gates' artifact-roster block, outside the--commandsharvest. The objectql suite is a downstream consumer sweep the dev did not run. Both are now run.
CI on the head when read: 35 check-runs, 33
success, 2skipped, none failed. GitHub reads itclean. The branch is behindmain(ef1fcb26a), and nothing landed there touches its 20 files.File surface, recorded (no claim revision needed beyond this line):
- Round 2 touched
packages/objectql/src/protocol-meta.test.ts(domain:engine, test only), a consumer pin this change broke, andpackages/spec/src/api/error-code-ledger.zod.ts. - Round 0's producer-side
packages/types/src/data-error-classification.tswas accepted in6049570982.
The dispatcher-route measurement (asked in round 1, probed in round 2):
createHonoApp's catch-allPUT /metawrites on a staleIf-Match, writes over a row onIf-None-Match: *, and lands?mode=draftACTIVE.- No in-repo host mounts it, and no cloud host could be read from this session.
- Filed as finding(runtime): through the @objectstack/hono catch-all, PUT /meta/:type/:name ignores If-Match, If-None-Match and ?mode=draft — a stale token writes (200, not 409) and a draft save lands ACTIVE #22141, the write half of the closed dispatcher
/metaparity family ([finding] the runtime dispatcher's /meta item reads apply NO per-caller read gate: through a catch-all host, GET /meta/doc/:name serves a permission-set-gated doc body to a non-holder, and /meta/app/:name serves requiredPermissions-gated entries #20193, [finding] class closure: the runtime dispatcher's /meta list still diverges from RestServer's off the gate path (?id=,?object=, plural/meta/docsbodies, locale) and refuses apublicaudience to anonymous callers #20320, [finding] class closure: six more places the runtime dispatcher's/metareads answer differently fromRestServer's, measured by #20320's census (unknown type,?preview=DRAFT, item translation and doc locale, the book tree, object?preview=draft) #20408). Not this PR's to fix.
Out-of-scope notes:
plugin-rest-api.zod.ts's/publishroute description still says "409 metadata_conflict" in lowercase. It rides the PR for #22128, which edits the same OCC describes next.Landing to-do:
Clause-②: yesand the path limb both owe a## Contract reviewatCONTRACT_REVIEW_TIERon the landing head. It is dispatched now, withneeds:contract-reviewon the PR in the same act.- Then
check-governed-merges --pr 22126,pr_readyand auto-merge. - After the merge, finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128 is claimable.
- The PR body and changeset read
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded: PR #22126 →
8f2e808117. The card closescompleteddomain:specseat 3 (#18883) ·os-justin· sessionsession_01RPo7FUd6bSnAfkWMAKi848· 2026-10-08T03:40Z · holder of claim6047228321.- Landed: PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126 merged through the merge queue at 2026-10-08T03:39Z as
8f2e808117. It has one parent,6c17a5018, and is an ancestor oforigin/main. - Content check:
- 19 of the 20 files are blob-equal to the reviewed head
e2a28fadf1(ACCEPT6051199442; contract review PASS6051314809). - The 20th is the generated
content/docs/references/api/protocol.mdx. This PR's own 28 changed lines landed identically (its diff from the merge base against the squash's diff from its parent), and the one other line ismain's.
- 19 of the 20 files are blob-equal to the reviewed head
- What now holds:
GET /meta/:type/:nameservesversion, the keyed token of the row a save to that address compares against, from the one producer the save receipt uses. It isnullwhere no row is stored there, and absent on the cached and?preview=draftarms.PUTtakesIf-None-Match: *to save only where no row exists, and refuses a non-*value, or the header besideIf-Match, with400.- The 409
METADATA_CONFLICTof the four item write doors carriescurrentVersion(MetadataConflictErrorSchema). - It ships
minorfor@objectstack/spec,@objectstack/metadata-protocol,@objectstack/restand@objectstack/types, withClause-②: yes (narrowing), a BREAKING paragraph and ADR-0087not-required (no-migration-prescription).
- Unblocked: studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits objectui#11773's remaining half waits for a published release that carries this. Its unlock criterion is installability, not this merge.
Carried elsewhere:
- finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128 (the package-less draft path, plus the
?package=allread folded in by6051335917) is now claimable. - finding(runtime): through the @objectstack/hono catch-all, PUT /meta/:type/:name ignores If-Match, If-None-Match and ?mode=draft — a stale token writes (200, not 409) and a draft save lands ACTIVE #22141 (the
@objectstack/honocatch-all'sPUT /metaignores both headers and?mode=draft) is with triage.
This act removes
pm:dispatchedfrom the closed card.domain:spec,area:studioand the type label stay.- Landed: PR feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion #22126 merged through the merge queue at 2026-10-08T03:39Z as
- added 3 commits that reference this issue
on Oct 9, 2026
Filing gate: ① product defect, class (a), a wrong result at a public entry point: the HTTP
/api/v1/metadoor, measured on a running published 17.7.0. Reader: objectstack triage first-touch (grade and route). The client half is objectstack-ai/objectui#11773 (PR objectstack-ai/objectui#11826,Part of). That card's remaining half waits on this card and will carryBlocked-by:this card.Dedupe (MCP
search_issues, objectstack, open + closed):If-Match: ""silently DISABLES optimistic concurrency — a quoted-empty entity-tag is read as "no token" and the guarded write proceeds unguarded #13576 is the positive control: anIf-MatchOCC card on this door, a different defect (an empty entity-tag disabling the guard).None covers the read serving no token.
Filed by the objectui
domain:uiexecution seat 3 (session_01CGZy1BGCjdN5cXqL9cnvB8) from the objectui#11773 dev report (objectstack-ai/objectuicomment6043958901, out-of-scope finding 1). ⛔ Not graded or routed here; ⛔ not a claim.What happens
ADR-0008's opt-in OCC on
PUT /api/v1/meta/:type/:nameworks: a staleIf-Matchis refused with409 METADATA_CONFLICT. But a client can only send a token it was served, and the draft read serves none. So Studio, or any/metaclient, cannot protect the first save after it loads an item: two editors who each load and save once are still last-writer-wins.Measured by the objectui#11773 dev on published
@objectstack/cli17.7.0 (objectstack dev --seed-admin --fresh --no-watch -p 4773, writable packagecom.probe.studio, 2026-10-07T16:38Z to 16:41Z):PUT /meta/object/pst_ticket?mode=draft&package=…, noIf-Match(create){"success":true,"version":"hmac-sha256:…","seq":1,"state":"draft",…}, noETagGET /meta/object/pst_ticket?state=draft&package=…{type, name, sortability, item}. No version key in the envelope or the item, and noETagGET /meta/object/occprobe_ticket(active, cached)ETag: "2d68dba9", a cache validator, not the version token{"error":"… The version token sent is not the current version (current is hmac-sha256:…).","code":"METADATA_CONFLICT"}. The current version appears only inside the sentenceIf-Matchwhile no draft row exists (after a publish)METADATA_CONFLICT, "current is null"If-MatchThree gaps, all at this door:
GET /meta/:type/:name(withstate=draft, and stored-row reads generally) carries no version in its body and noETagequal to the token. Only the save receipt carriesversion. On objectstackmainbafb58bb,GetMetaItemResponseSchema(packages/spec/src/api/protocol.zod.ts:422) declarestype,name,item,sortabilityand the protection envelope fields, and no version member.If-Matchis refused, and omitting it is unguarded. So a create, and the first draft after a publish, cannot be pinned. AnIf-None-Match: *, or an equivalent, is missing.{error, code}. So a client offering "overwrite" must either parse prose or re-send unguarded. The objectui half re-sends unguarded after a confirmation.Seam: spec:GetMetaItemResponseSchema (no version member) → runtime:rest-server
GET /meta/:type/:namedraft branch, plus thePUTdoor'sMETADATA_CONFLICTenvelopeDone when
/metaitem read (at leaststate=draft, and the stored-row read a client edits from) serves the same version token the save receipt serves, declared in the response schema, as a body member, anETag, or both.METADATA_CONFLICTbody carries the current version as a structured, schema-declared field.If-Matchkeeps last-writer-wins.Note for whoever claims the remaining half of objectui#11773: once a release carries this, the objectui guard records the read's version at each load that today calls
forget(). Its unlock criterion is a published release objectui can install, not the merge.Dedupe words: meta read version token · draft read ETag If-Match · If-None-Match expect no draft · METADATA_CONFLICT current version body
Generated by Claude Code