Uh oh!
There was an error while loading. Please reload this page.
feat(rest): mount the compound-name per-item promotion door POST /meta/:type/:section/:name/publish (#11932) - #12105
feat(rest): mount the compound-name per-item promotion door POST /meta/:type/:section/:name/publish (#11932)#12105claude[bot] wants to merge 3 commits into
POST /meta/:type/:section/:name/publish (#11932)#12105Conversation
…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
…he helper
The ablation caught it: `expect(stack.compoundPublish).toBeTypeOf('function')`
is true whether or not the route exists, so that case passed with the source
change reverted — a vacuous pin sitting in the middle of a suite whose whole
claim is that the door is mounted. It now asserts the composed server's own
`POST …/publish` patterns, both arities, in registration order.
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): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 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 6560a73a4d8a5dd242ff9768aceb4ed5cfea0d0a && git checkout 6560a73a4d8a5dd242ff9768aceb4ed5cfea0d0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 399ecad58033006635b69d645b4df1d2091c98e5 2326dd5d4f882908f04f6bebbcf4849d445ee44b && git checkout -B drift-repro 399ecad58033006635b69d645b4df1d2091c98e5 && git merge --no-ff 2326dd5d4f882908f04f6bebbcf4849d445ee44b
node scripts/docs-audit/affected-docs.mjs --json 399ecad58033006635b69d645b4df1d2091c98e5
|
… double
`check:objectql-double-limit` went red on this PR (Lint & Repo Gates, job
97745771073) naming the test file this PR itself adds:
packages/rest/src/meta-compound-publish-door.test.ts: NEW ObjectQL `find`
double that does not hold the caller's bound (1 blind, 0 breaking a shape
rule, 0 unjudged).
line 175: BLIND
WHY THIS PR TRIPS IT. The gate is a ratchet whose baseline never grows, and
this PR adds a new fake engine whose `find` matched `where` and handed back
every matched row — limit-blind by the gate's definition. The gate itself
landed on `main` AFTER this branch was cut, which is why it appears in neither
the PR body's 40 derived gates nor any local derivation on this branch:
`scripts/pm/dispatch-gates.mjs` derives families from the tree it is run in,
and at this branch's commit that gate does not exist. CI sees it because CI
tests the merge with `main`.
THE REMEDY IS MEASURED, NOT GUESSED. The gate prints two conforming exits —
apply the bound by presence after the filter, or throw when handed a bound the
double does not implement. Which one is right turns on whether any caller in
this suite actually passes a bound, so that was measured rather than assumed:
the double was instrumented at the seam and the suite run whole (15 cases).
find 18 calls — every one `sys_metadata_history`,
keys ["where","context"], typeof o.limit === 'undefined'
bound 0 calls carrying `limit` in any form
findOne 54 calls — the CONTROL, proving the instrument records at all
The control matters: a first attempt probed via `console.error`, which vitest
swallowed, and read zero for both. A zero with no control is not a reading.
So no caller passes a bound, and a `slice()` branch would be a line no case in
this suite executes. Dead code cannot be relied on to be right on the day it
finally runs. A throw instead states what this fake actually is — it does not
implement paging — and turns the day some case starts handing it a bound into
a RED rather than a quiet full scan that reads exactly like a passing test.
It also matches this file's own established idiom: `match` already throws on
unsupported logical operators. Presence (`!== undefined`), not truthiness, so
`limit: 0` — a request for NOTHING — refuses too instead of being answered
with the whole table.
WHY THIS IS NOT A GATE BYPASS. Refusal is a conforming exit the gate defines
and self-tests ("the refusing fixture is CONFORMING"), for its own recorded
reason: the defect class is SILENCE, not incompleteness. The baseline was not
touched, no gate was skipped, relaxed or edited, and the census moved in the
one direction a genuine repair moves it:
before 77 apply the bound or refuse it loudly (0 refuse), 169 limit-blind
after 78 apply the bound or refuse it loudly (1 refuse), 168 limit-blind
VERIFIED at this commit's tree. `check:objectql-double-limit` red reproduced
before the change and green after. Because the ESLint job runs its gates
sequentially and the first failure aborts the rest, the 46 gates sitting after
this one in lint.yml had never run on this PR; the ones this change set can
move were run here — 21 script gates plus check:type-check-coverage, all
exit 0 — along with `pnpm --filter @objectstack/rest test`
(147 files / 2385 tests passed, unchanged), `--filter @objectstack/rest
typecheck`, and whole-repo `eslint . --no-inline-config` (0 findings).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqRos-zhuang
commented
Aug 25, 2026
|
| calls | shape | |
|---|---|---|
find | 18 | every one sys_metadata_history, keys ["where","context"], typeof o.limit === 'undefined' |
| carrying a bound | 0 | no call carries limit in any form |
findOne | 54 | the control — proves the instrument records at all |
The control is load-bearing: a first attempt probed through console.error, which vitest swallowed, and read zero for both. A zero with no control is not a reading.
So no caller passes a bound, and a slice() branch would be a line no case in this suite executes. Dead code cannot be relied on to be right on the day it finally runs. The throw states what this fake actually is — it does not implement paging — and turns the day some case starts handing it a bound into a RED rather than a quiet full scan that reads exactly like a passing test. It also matches this file's own idiom: match already throws on unsupported logical operators. Presence (!== undefined), not truthiness, so limit: 0 refuses too rather than being answered with the whole table.
Not a gate bypass
Refusal is a conforming exit the gate defines and self-tests ("the refusing fixture is CONFORMING"), for its own recorded reason: the defect class is SILENCE, not incompleteness. The baseline was not touched — it never grows — and no gate was skipped, relaxed or edited. The census moved in the one direction a genuine repair moves it:
before 77 apply the bound or refuse it loudly (0 refuse), 169 limit-blind
after 78 apply the bound or refuse it loudly (1 refuse), 168 limit-blind
The red was masking 46 more gates
The ESLint job runs its gates sequentially and the first failure aborts the rest. check:where-matcher sits at lint.yml:2568, before the failure at :2590, so it had run; the 46 gates after it had never run on this PR at all. The ones this change set can move were run here, all at 2326dd5d4, exit codes captured before any pipe:
exit 0 check:nul-bytes · check:where-matcher · check:engine-double-contract ·
check:query-options-erasure · check:test-source-alias · check:slot-lookup ·
check:pnpm-filter-targets · check:agent-test-spelling · check:entry-guard ·
check:parse-guard · check:published-files · check:type-source-resolution ·
check:dispatcher-error-vocabulary · check:route-envelope · check:authz-resolver ·
check:kernel-hook-pairs · check:resume-authority-declared · check:merge-driver ·
check:spec-parsed-alias · check:tenant-chokepoint · check:cross-package-test-inputs ·
check:type-check-coverage
exit 0 check:objectql-double-limit
"OK ObjectQL double `limit` conformance holds: 278 double(s) graded,
78 apply the caller's bound or refuse it loudly."
exit 0 pnpm --filter @objectstack/rest test
"Test Files 147 passed (147) · Tests 2385 passed (2385)" (unchanged)
exit 0 pnpm --filter @objectstack/rest typecheck
exit 0 eslint . --no-inline-config (full repo, 0 findings)
No other red surfaced.
Declared narrowing (one):check:type-check-debt --re-measure was not run. It re-runs tsc per ledger entry and packages/rest carries none — its only two mentions in scripts/check-type-check-coverage.mjs (lines 2668, 2887) are commentary — so this diff cannot move it. The structural half, check:type-check-coverage, was run and is green. This matches the PR body's existing note.
One further reading, recorded rather than acted on: the gate prints NOT verified: could not read the baseline at the merge base with main, because the baseline file does not exist at this branch's merge base. That check is unexercised locally for the same reason the gate was invisible; CI verifies it against the merge.
Unchanged: still draft, auto-merge not armed, needs:contract-review still hung on both carriers, PR body not rewritten.
Generated by Claude Code
Generated by Claude Code
os-trump
commented
Aug 25, 2026
|
os-trump
commented
Aug 25, 2026
Closed by maintainer ruling — superseded by the name-grammar retirement (#12176)Provenance (who / verbatim / where): the maintainer, PM chat session 2026-08-25 — direction ruling 「我认为斜杠不该存在」 · 「斜杠不该存在,现在就立卡,你负责派发,然后给我结果」, then the land-vs-close fork was presented with the full four-axis analysis and the maintainer accepted the recommendation (close): 「我看到你的意见了,我同意」. Recorded by PM session Grounds, from #12176's phase-1 census (all measured, each zero beside a firing control): zero producers construct a compound name anywhere in the repo, zero stored rows carry one — this door's only traffic is test traffic. Mounting a fourth compound door in the week the convention is ruled retired would hand D3 a fourth retirement and hand AI authors a fresh documented invitation to the exact shape D1 will refuse. The morning contract-gate clearance on #11932 was made under the old convention and is superseded, not faulted. The engineering in this PR is not lost: the route-shadowing measurements, the capability-enumeration table pattern, and the door-to-door parity pin shape are recorded on #11932 / #12106 / #12176 and will be reused by D3 (which retires the remaining compound arities and resolves all five broken SDK methods at once). Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11932
Mounts
POST /api/v1/meta/:type/:section/:name/publish— the compound-namearity of the per-item promotion door. Both arities now come out of one
two-entry registration loop, the same shape the ADR-0033
/publishedread twinhas carried since #7526.
Re-derived offsets — nothing reconciled to the order
Triage measured the promote door at
rest-server.ts:5936and thepublishedread at
:6389. PR #11985 landed in this file afterwards, so both had moved.Measured on
origin/mainat1e79aa4f8, before any edit:Every one of the three had moved. After this PR (
fef09c1e6): the two-entryloop opens at
rest-server.ts:6086,path: publishPathat:6092, thecompound-name assembly at
:6262, and the new ledger row atrest-route-ledger.ts:183.The premise held — and it is the ROUTE that was missing, not the capability
Verified rather than relayed, driving the real
ObjectStackProtocolImplementationover asys_metadata-backed engine againsta seeded compound draft, before this route existed:
publishMetaItemkeys the draft on type/name/organization/package and reads thename's spelling nowhere. The same measurement read the whole
/metamount tableoff the real registered server and confirmed the asymmetry the card describes:
PUTandGET …/publishedmounted in both arities,POST …/publishin one.An unplanned corroboration:
client.meta.publishItemalready constructs thisURL — it interpolates the name unencoded and its own doc comment says
"Compound names pass through unencoded, like
getItem". The SDK promised a doorthe server had not mounted, and #3642's URL guard could not see it because the
compound name goes into the same
${name}slot the single-arity patterndeclares.
Route ordering — measured, and the honest shape of the constraint
The card names the first-match-wins trap. It is real for this pattern and it is
currently latent, and this PR states the difference rather than blurring it.
Measured against a real
HonoHttpServer(resolveMountedRoute, the samelive-router observation the #7526 dogfood parity gate reads):
So only a same-arity sibling can shadow this path. The live table has none:
the complete set of
POSTroutes under/metais_migrate-stored,:type/:name/publish,:type/:section/:name/publish,:type/:name/rollback—no
POSTcatch-all exists, unlike theGETside whereGET /:type/:section/:namereally does swallow its literal siblings.That census is pinned as a list in
meta-route-registration-order.test.ts,so the day a
POSTcatch-all is added the latent constraint becomes live andthis reddens. Reachability itself is probed against the live router rather than
inferred from a position in the table, and the wrong-order case above is in the
suite as the demonstration.
Anti-vacuity
the door through
RestServer.getRoutes()and drives it againstObjectStackProtocolImplementationover asys_metadata-backed engine — theshape fix(rest): thread
?mode=drafton the compound-name metadata write door #11933's pins next door use.[live label, staged label]: a promotion must leave the staged body LIVE andnothing staged (
['Staged label', undefined]), and must leave thesingle-segment twin's rows untouched. A status-only pin cannot see The compound-name
PUT /meta/:type/:section/:namenever threads?mode=draft, while its single-segment twin does — the fourth divergence closed, a fifth left open #11712'sdefect class.
on status, error code and store outcome across five request shapes without
naming what they agree on, so a future move on either door reddens
independently.
404when nothing is staged — the 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 disguise (aroute that structurally cannot refuse) refused.
reachability probe is shown returning
undefinedforPOST /meta/object/crm/task/rollback, a path this build genuinely does notmount — exactly what the publish path answered before this PR.
in
meta-write-door-capability-enumeration.test.ts'sDOORStable, soThe REST
/metapublish and rollback doors carry nomanage_metadatagate, so the authoring capability the PUT/DELETE doors enforce is reachable around #8919's403-and-never-reached case now runs against it too. That table isthe anti-drift assertion that caught this PR: adding a mutating
/metaroutefails the suite until the door declares its gate.
PUT /:type/:section/:name?mode=draft, promote through the new door, read theedited body as live. That is precisely the sequence fix(rest): thread
?mode=drafton the compound-name metadata write door #11933's changeset toldconsumers was impossible.
Ablation — predicted vs measured
Revert only
packages/rest/src/rest-server.tstoorigin/main, keep everypin, run. The mutation is confirmed on disk with anchored greps in both
directions before each leg, and the restore is confirmed byte-identical
(
git diff HEAD= 0 bytes) — an editing tool's exit code is not evidence:The 26 reds, per file:
meta-compound-publish-door.test.tsmeta-write-door-capability-enumeration.test.tsmeta-route-registration-order.test.tsrest-write-response-internal-fields.tripwire.test.tsNo
dist/on the measured path for the ablated change. The suites importRestServerfrom./rest-server.js— a relative source import vitest compilesfrom source — and
packages/rest/vitest.config.tsaliases@objectstack/plugin-hono-serverto the sibling'ssrc/. The ablation goingred is itself the proof: a dist-shadowed path would have stayed green. What
does resolve through
exportstodist/is@objectstack/metadata-protocol(registered in
check-test-source-alias.mjs'sKNOWN_UNALIASED_TEST_IMPORTSfor this package) — nothing in this diff touches that package, so no rebuild
sits between the mutation and the reading; the dependency closure was built
once up front (
pnpm --filter '@objectstack/rest^...' build).Two predictions were wrong, and the corrections are the useful part.
rest-write-response-internal-fields.tripwire.test.tswas predicted greenin both states — I read its disposition check as one-directional (a route
with no entry fails). It is bidirectional: it also fails on a stale
entry naming a route that no longer exists, so ablated my new key is stale
and it goes red. Measured, not reasoned: predicted green-both-ways, observed
red-when-ablated.
expect(stack.compoundPublish).toBeTypeOf('function')is true whether or notthe route exists and passed the ablation. It is now
expect(stack.mountedPublishDoors()).toEqual([SINGLE_PUBLISH, COMPOUND_PUBLISH]),read off the composed server's own table, and reddens when ablated
(
efd007b73). Round 1 measured 25 reds; round 2, after the fix, measures 26.Green in both states — regression guards, ⛔ not red-before evidence:
meta-compound-publish-door.test.ts§5 "and the twin is UNTOUCHED — itsprotocol request is what it always was" (the fence on the single-segment
door).
meta-route-registration-order.test.ts: "the single-segment spelling stillresolves to its OWN pattern" (fence), "⭐ the instrument returns a NEGATIVE
for a path this build does not mount" (the canary — green both ways by
design: its job is to prove the probe can answer
undefined), and the twosynthetic-router cases ("shows what a wrong-place registration would do",
"a DIFFERENT-arity sibling cannot absorb it") which register their own
fixtures and never read
rest-server.ts.Clause ② — yes, and this is what it applies to
Mounting a route expands the public surface, so
needs:contract-reviewis hungon this PR. Concretely, what widened and what did not:
POST /api/v1/meta/:type/:section/:name/publishanswers where itused to reach the transport's
notFound.404→200/403/404 [no_draft]depending on the request.
door inherits the
manage_metadatagate (ADR-0066 D1), the?packagebindingand its
#6877repeated-parameter refusal, the#7749write-actorresolution, the organization scoping, the
404 [no_draft]answer and the501envelope — each pinned. The single-segment door's request shape andanswers are unchanged, asserted rather than assumed.
newly refused.
client.meta.publishItem(type, name)already built thisURL; it returned
404for a compound name before and promotes after.Also in this diff, and why
packages/rest/src/rest-route-ledger.ts— the new mount must be ledgered: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's parity gate asserts both directions, and an unledgered live mountfails it.
.changeset/compound-meta-door-mode-draft.md— fix(rest): thread?mode=drafton the compound-name metadata write door #11933's still-unreleasedchangeset carries a paragraph that says this promotion door does not exist.
Both entries compile into one release, so left standing it would tell a reader
in one paragraph that the door does not exist and in the next that it does.
The paragraph is corrected in place, not deleted, and says so.
content/docs/kernel/contracts/metadata-service.mdx— the one hand-writtenpage that enumerates the metadata REST doors. Its prose said promoting a
single item "is done via the
/meta/:type/:name/publishroute", which thischange makes incomplete for a compound-named item. One sentence and one table
row. No gate covers this class:
node scripts/docs-audit/check-affected-docs.mjsexits 0 and reports the
/metaledger rows as unreachable for the SDK-routebridge (48 of 88 rows on
rest-route-ledger.ts), so the drift check could nothave named this page.
scripts/engine-double-contract.pinned.json— the new suite's fake engineroutes
update/deletethrough the producer-side predicates, andcheck:engine-double-contractrequires new pinned coverage to be recorded(
--write, its own prescribed repair). Not a baseline raise: the ledger itgrew is the RETAINED (pinned) one, not the DEBT one.
packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts— theThe write-response internal-field tripwire walks the protocol class only — a direct engine write mouth outside it (rest-server's batch ql.update) is covered by the fix but not by the guard #8497 disposition table demands an entry for every write route. Green in
both ablation states — a regression guard, not red-before evidence.
Verification
Every line below is the gate's own verdict line, captured on the final commit
efd007b73. Exit codes captured before any pipe.Tests / types (
@objectstack/rest, the only package this diff touches):Full-repo lint — run in full, not narrowed:
Gate family, re-derived from the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack— andre-derived twice more as the change set grew (once when
scripts/engine-double-contract.pinned.jsonwas added, which pulled in thescripts/**families; once when the docs page was added, which pulled in ~14docs families). All 40 derived local gates were run; the load-bearing verdict
lines:
Not run locally, and why:
check:type-check-debt --re-measurerefuses on aworktree without the whole workspace closure built, and building all 78 packages
in a container this contended is CI's job — it runs the farm exactly once
regardless.
packages/qa/dogfood's route-ledger ↔ live-mount parity gate is abooted-stack test in another package; the new ledger row is what it needs, and
its unit-level twin (
meta-route-registration-order.test.ts) is in this diff.⛔ Not marked ready and auto-merge not armed — the PM seat's step.
Generated by Claude Code