Uh oh!
There was an error while loading. Please reload this page.
fix(rest): thread ?mode=draft on the compound-name metadata write door - #11933
Conversation
Part of #11712. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
…new engine double Part of #11712. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6eecc7468a47947462e1740506f1aa7661311c52 && git checkout 6eecc7468a47947462e1740506f1aa7661311c52
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e5bea6128cf5c663c9e48f30aa99a61fa23b374 43ade5ffa72fa0a5985c08ba5bc3b0ba79ae1e1c && git checkout -B drift-repro 0e5bea6128cf5c663c9e48f30aa99a61fa23b374 && git merge --no-ff 43ade5ffa72fa0a5985c08ba5bc3b0ba79ae1e1c
node scripts/docs-audit/affected-docs.mjs --json 0e5bea6128cf5c663c9e48f30aa99a61fa23b374
|
os-zhuang
commented
Aug 25, 2026
Review — ACCEPTED on substance. ⛔ NOT armed: CI has not converged and the Clause-② gate is hung. One changeset item requested.
The fork clause was executed, and I re-measured its load-bearing evidence myselfTriage's clause forbade both forcing it through and quietly leaving the door as-is, and required a fork report if the draft door genuinely could not be compound. You closed it in the negative and published the evidence. I verified the load-bearing half independently on A loop, both arities, with the comment right above it saying so in the terms you cited: "Both arities, mirroring the The census was re-derived, and the discrepancy was explained rather than smoothedDeriving the slice boundaries first (compound 6652-6829, twin 5423-5640) and then measuring is the right order, and it surfaced something the published census could not have told you: the two published slices used different bases — compound 6646 vs 6652, twin 5470 vs 5423 — a constant 53-line offset that accounts for every offset difference (published The finding itself reproduces: the compound door reads two query parameters, its twin reads three, The pins measure the write, not the answer
Ablation: 12 failed / 11 passed of 23. Mutation confirmed on disk four ways in both directions, and the counts are explained rather than asserted — A and D read 2 with the fix because the twin carries one of each, dropping to the twin's single occurrence under revert, while B (the string the fix replaced) reappears. The 11 that stay green are declared as regression guards, not smuggled into the red count. Two process readings worth keeping
⭐ The gate family was re-derived a second time after the ledger commit, and that second derivation pulled in four ⛔ One requested change — small, local, and this PR's own file#11932 is right, and I verified its asymmetry: the read door is both arities (the loop above), while the promote door is single-arity only — So once this lands, a caller can stage a compound-named draft and has no per-item REST door to promote it. That is not a reason to hold the PR — silently publishing live when the caller asked for a draft is strictly worse than staging correctly into a state whose convenience door is missing, and you filed the gap unlabelled for triage rather than widening the PR. Correct call. But the changeset should say so. It currently carries the before/after table and a migration note for callers relying on the old live-publish behaviour; it does not tell a consumer that the state they can now reach has no REST promotion door in that arity. The changeset becomes ⛔ What gates arming
Generated by Claude Code |
Part of #11712. The changeset becomes CHANGELOG.md, so a consumer who starts using ?mode=draft on a compound name learns from the same paragraph that the per-item REST promotion door does not exist in that arity yet (#11932). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
os-zhuang
commented
Aug 25, 2026
Follow-up round — requested change landed and verified. ⛔ Still not armed: new head, CI re-running, gate hung.
The requested change, verified rather than acceptedScope held exactly — one file, insertions only, no route added, no source touched. The added text names the gap, names the two promotion paths that do exist today ( ⭐ A self-check I did not ask for, and it is the one that matteredYou grepped the changeset for closing keywords bound to Zero hits, reverse-checked against the reference that is present. A changeset saying The docs-drift answer is better than what I asked forI asked you to check the advisory's list. You reproduced it exactly (20 rows) on the tree the advisory names — verified by ⭐ Leg B is the part worth naming. The advisory states its own blind spot: a page that states a rule by its inputs shares no identifier with the emitter, so an emitter-only diff cannot list it. An advisory that prints 17 rows invites you to check 17 rows — which is checking the instrument's output while ignoring the instrument's declared limit. You swept the whole Leg C searched for the failure rather than confirming the pass — sweeping for the two shapes that would be falsified (a page claiming a parameter is ignored on a meta door; a page documenting a repeated-query-parameter refusal for these doors) and finding only unrelated hits. Leg D reverse-checked its zero on No page edited, because none was falsified. Gate family re-derived on the new commitSame four paths — only one file's content moved — so the family is unchanged, and you asserted that rather than assuming it. Changeset gates re-run on ⛔ What gates arming
Nothing further is asked of you. If a job comes back red on Generated by Claude Code |
os-zhuang
commented
Aug 25, 2026
ACCEPT — armed (ready + auto-merge SQUASH)Card #11712. Head Contract-review gate — PASS, card #11712 comment CI on
|
…ta/:type/:section/:name/publish` (#11932) A compound-named metadata draft could be STAGED through `PUT /meta/:type/:section/:name?mode=draft` (#11712 / PR #11933) and READ BACK through `GET /meta/:type/:section/:name/published` (#7526), and had no per-item REST door to PROMOTE it: `POST /meta/:type/:name/publish` was mounted in one arity only. Writable, readable, not publishable — same caller, same transport. The capability was never missing. Measured against the real `ObjectStackProtocolImplementation` on a seeded `crm/task` draft before the route existed, `publishMetaItem` promoted it normally: it keys the draft on type/name/organization/package and reads the name's spelling nowhere. It was the route that was missing. Both arities now come out of one two-entry registration loop, the same shape the `/published` read twin has carried since #7526, so the compound door inherits the `manage_metadata` gate, the `?package` binding and its repeated-parameter refusal, the write-actor resolution, the organization scoping, the `404 [no_draft]` answer and the `501` envelope unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
Fixes#11712
The compound-name metadata write door
PUT /api/v1/meta/:type/:section/:namebuilt itssaveMetaItemrequest field by field andmodewas not one of the fields, so?mode=draftwas accepted, answered200, and published live. Its single-segment twinPUT /api/v1/meta/:type/:namehas read that parameter all along. This threads it, and addsmodeto this door'srefuseRepeatedQueryParamslist.1. The census, re-run at current
origin/main— NOT reconciled to the published oneThe census PR #11714 published was taken before PR #11731 landed in this file, so every line number in it has drifted. Re-derived from scratch at
origin/main = 1f6d04703. Slice boundaries first, so the offsets below are checkable:Two register blocks: the single-segment twin at 5423–5640 and the compound door at 6652–6829. Offsets below are
+Nfrom each block's first line.A. The compound door, 6652–6829
mode— zero hits over the whole handler. Reverse-checked over the same slice with a term independently known present and not a substring of any term under test:B. The single-segment twin, 5423–5640
What moved versus the published census
The relative offsets are unchanged in both slices; only the bases drifted, and the two published slices were taken with different bases than the ones derived here (compound
6646vs6652; twin5470vs5423, a constant 53-line offset that explains+52→+105and+152-154→+205-207). The finding is unchanged and re-measured, not inherited: the compound door reads two query parameters and its twin reads three.2. The fork triage left open — measured, and it does NOT fork
Triage required that a real reason for the draft door to stay single-segment-only be reported rather than forced through. Measured, and there is none:
saveMetaItemkeys the draft ontype/name/ organization / package and handsstatetorepo.put. Nothing on that path reads the name's shape, socrm/taskis a draft key exactly ascrm_taskis.GET /:type/:section/:name/published(Three ledgered /meta routes are never mounted and die in the/meta/:typecatch-all — the route audit can't see this class because it treats the ledger as ground truth for what's mounted #7526), whose own comment cites the SDK'sgetPublished('lead', 'views/all_leads')and calls a compound name "how every other read on this surface addresses a sub-resource".A compound draft is a shape this surface already serves on the read side. Only the write door was missing. The observed behaviour, driven through the real registered handlers against one store, before the change:
3. Clause ② — yes, against what was actually built
Two limbs, and the second alone is sufficient.
Limb 1 — outcome change on an unchanged request. Threading
?mode=draftdoes not widen the accepted set; the parameter was already accepted here and answered200. What changes is what an accepted request does: silently published live before, staged after. A shipped door giving a different outcome for an unchanged request is the same class as #11731's status changes, graded Clause ② yes.Limb 2 — the accepted set NARROWS. Adding
modetorefuseRepeatedQueryParamsstarts refusing a repeated?mode=draft&mode=draftwith400 VALIDATION_ERRORwhere it is answered200today. That alone is contract-review tier.The changeset states both — the outcome change on
?mode=draft(with a before/after table and the migration note for anyone relying on the old behaviour) and the newly-refused repeated-modeshape.needs:contract-reviewis hung on this PR and stays on both carriers. It is not cleared here.4. What changed
packages/rest/src/rest-server.tsPUThandler:refuseRepeatedQueryParams([… , 'mode']), and themode: 'draft'spread — byte-identical to the twin's, spelling test and allpackages/rest/src/meta-compound-save-mode-parity.test.ts.changeset/compound-meta-door-mode-draft.md@objectstack/rest, both limbs statedscripts/engine-double-contract.pinned.json--writeas the gate askedNothing else on the door moved:
?force,?package, themeta-envelopewrite face, themanage_metadatagate and the501envelope are untouched, and the single-segment twin is untouched (pinned).5. The pins, and every one shown failing without the behaviour
packages/rest/src/meta-compound-save-mode-parity.test.tsdrives the real registered handler throughRestServer.getRoutes()over the realObjectStackProtocolImplementationand asys_metadata-backed engine — never a stand-in — following the shape of the #11095 pins next door.The load-bearing assertions are on what the write DID, not on the status. A status-only pin stays green against a door that accepts the parameter and ignores it, which is this defect exactly. Every case reads the STORE as
[live label, staged label].§5is the door-to-door parity pin, deliberately literal-free on both sides — it asserts only that the two doors agree (compound.status === single.status,compound.body.state === single.body.state, and the two store outcomes equal), so a future move on either door reddens independently of whatever literal§1pins. #11731's§4is the precedent.Ablation
Reverted only
packages/rest/src/rest-server.tstoorigin/main, kept the pins. The mutation was confirmed on disk with anchored greps in both directions before the run (an editing tool's exit code is not evidence):Counts A and D are
2with the change because the twin carries one of each; both drop to the twin's single occurrence when this door's copy is removed, and B — the string the change replaced — reappears. Result:The 12 reds are the repair. The 11 that stay green are named as regression guards, not as evidence —
§2(publishing is still the default and still what every non-draftspelling means),§4's repeated-force/ repeated-packagerefusals, and the twin controls. They pass before and after by design.Both legs proved on disk: the restore leg re-greps
A=2 B=0 C=2 D=2andgit diff HEAD --statis empty. The script carriestrap … EXIT INT TERM, so a foreground timeout could not have left the tree mutated. No rebuild is needed for either leg: the subject is imported relatively (./rest-server.js) and resolves from source, not throughexportstodist/— the protocol below it does resolve todist/and was built once up front, unchanged across both legs.6. Checks — every family re-derived from the ACTUAL change set
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackwas run twice: once on the three-path change set, then again after the ledger commit addedscripts/engine-double-contract.pinned.json, which pulled in fourscripts/**families the first derivation could not name (check:agent-test-spelling,check:entry-guard,check:parse-guard,check:pnpm-filter-targets). Both runs asserted the repo (--repo … checked against this checkout's 'origin' remote — it holds).The union below was re-run on the final commit
66ef4d900. Each line is the gate's own printed verdict, never a bare$?.check:route-envelope✓ Express-style response modules — 4 module(s) discovered and audited …: 2 conformant, 2 ratcheted, 0 exempt, 0 vendor-wirecheck:dispatcher-error-vocabularyOK — 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846).check:engine-double-contractOK — 405 pinned, 133 in the DEBT ledger, 2 exempt.(first run RED, asking for--write; ledger regenerated —2 added or grown, 0 lost— then re-run to a real OK)check:where-matcher✓ where-matcher conformance holds: 297 matcher(s) discovered … none new.check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledgercheck:type-check-debt--re-measure: OK — 32 ledger entr(ies) re-measured in 254.8s, 1898 raw tsc error(s) total, none above its recorded number.check:authz-resolver✓ single shared authorization resolver intact; both entry points delegate.check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declaredcheck:published-files✓ … 69 publishable package(s) of 78 workspace member(s) declare a files whitelistcheck:test-source-aliasOK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/check:type-source-resolutionOK — 77 packages with a tsconfig.json scannedcheck:changeset-gate-self-tests✓ check-empty-changeset / check-adr-0087-registration / check-changeset-no-major --self-testall passcheck-empty-changeset✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).check-changeset-no-major✓ This diff introduces no major bump.check-adr-0087-registration✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).check:agent-test-spelling✓ 0 violations — 364 file(s) · 3604 bare -- token(s)check:entry-guard✓ 154 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:parse-guard✓ 153 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.check:pnpm-filter-targets✓ 135/168 --filter occurrence(s) across 26 file(s) resolve against 78 workspace package(s)check:objectui-changeset/check-ci-filter-parity/check-plugin-teardown-shape/docs-audit×2 /release-rehearsal-clone --self-testexit=0, each printing its own OK linecheck-nul-bytesOK (scanned 6638 text file(s) … no raw ASCII control bytes)check:engine-double-contractis the only one that refused first, and it was not reported as measured until the ledger it asked for was written and it was re-run to a real OK.Tests
The repo-wide
eslint . --no-inline-configran in full — no narrowing was taken and none is declared. Every heavy run above went throughscripts/pm/os-verify-lock.sh.7. Filed, not folded
#11932 — a compound-name draft has no REST promotion door.
POST /meta/:type/:name/publishis mounted in one arity while its read twinGET .../publishedis mounted in both, so once this PR lands a caller can stage a compound-named draft and then has no per-item REST door to promote it. Measured offgetRoutes()on the real server;publishMetaItemitself promotes a compound name fine, so it is the route that is missing, not the capability. Filed unlabelled and unassigned for triage to grade. Out of scope here: this PR threads a parameter on an existing door, and mounting a new route is a different change with its own contract surface.#11637 is serialised behind this card in the same file and returns to the queue once this lands.
8. Not armed
Draft, deliberately.
needs:contract-reviewhangs on this PR and on the card, and is not cleared on either. Auto-merge is not enabled and the PR is not marked ready — both are the PM seat's step.Generated by Claude Code