Repository navigation
meta-publish-verb-skips-the-canonical-fold: publishMetaItem is the one /meta entry point that reads the MANIFEST map instead of the URL map #8769
Description
Activity
Triage: promoted
finding→pm:queue, routeddomain:metadata, type Task.Routing (read the code, not the title): the fix lands in
packages/metadata-protocol/src/protocol.ts, which ispackages/metadata*⇒domain:metadataby the anchoring rule. Same lane as its sibling seam #8421. ⛔ Notdomain:spec— no schema, accept-set orpackages/specsurface changes; the seam is a request-canonicalization call site.Premise re-verified independently on
origin/main@ab8b10f(not inherited — the card measuredf59a135f0, a PR branch, so its line numbers differ):publishMetaItemspans 12485–12966 and contains zerocanonicalizeMetaRequestTypecalls.- The draftability check folds through
PLURAL_TO_SINGULAR[request.type] ?? request.typeat :12646 — the manifest map, i.e. the lookup meta-plural-url-bypass:PUT /meta/fields/<name>walks around the whole two-tier registry gate — 4 registry types have no entry inPLURAL_TO_SINGULAR#7894 replaced at the siblings. - Row lookup and receipt use
request.typeraw at :12667, :12677, :12747. - Zero-hit counter-probe satisfied: the same grep is live elsewhere in the file (
canonicalizeMetaRequestType(request)at :5048, :5445, :5852, :8710), so the absence insidepublishMetaItemis real and not a broken query.
Not absorbed by the in-flight PR. Checked #8770's changed files directly: it adds
opts.mintingand passes{ minting: true }fromsaveMetaItemonly.publishMetaItemis untouched there, so this card survives that merge.Graded Task, not Bug — reasoning stated so the lane can push back. The publish route
/meta/:type/:name/publishedis not documented at all incontent/docs/api/metadata-api.mdx(verified: the file documentsGET /meta,GET /meta/:type,GET /meta/:type/:name,PUT /meta/:type/:nameand the/packagesfamily — nopublishedentry). So the "singular or plural address the same type" declaration at:26is not a user-facing contract this verb breaks. What it does break is the module's own stated invariant, and it is fail-closed with no measured pull ⇒ coherence restoration, not a defect report.⚠️ PM mechanism assumptions — implement by measuring, and fork-report if false⛔ These are not rulings. The card's "one-line change plus a pin" is the PM's inherited assumption, and two things could make it wrong:
- Do not blindly pass
minting: true. fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) #8770 scoped that flag deliberately (mint closes, reads stay permissive, deletes stay possible).publishMetaItempromotes an already-minted draft, so the plain foldcanonicalizeMetaRequestType(request)is the shape that matches fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) #8770's own rationale. Passing the minting verdict here would refuse the promotion of legacy rows minted before that refusal existed — the exact accumulation fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) #8770 kept deletable on purpose. - The four manifest-absent types are the real scope risk.
field,seed,external_catalog,translationreach the:12646draftability check as unrecognised and take the permissive plugin branch. Today that is unreachable because the row lookup fails first — folding correctly makes it reachable. So the fold does not merely canonicalize a spelling; it activates a draftability branch that has never executed for those four types. Measure what that branch decides for each of the four before calling this one line; if it changes a draftability verdict, that is a fork worth reporting, not a detail to absorb.
Serial constraints (same file — ⛔ not same-batch):
- meta-unknown-type-namespace: a
/metatype name that is not a plural of anything still mints a namespace —PUT /meta/fieldz/xanswers 200 #8421 / PR fix(metadata): refuse a /meta type name the platform does not have, instead of minting a namespace for it (#8421) #8770 — in flight,packages/metadata-protocol/src/protocol.ts. Wait for MERGED; the line numbers above shift when it lands, so re-derive them on the merged ref rather than quoting this comment. - [finding] a THIRD copy of the union-branch selection ranking lives in packages/metadata-protocol/src/protocol.ts — outside the reach of #8318's package-internal consolidation, with no mechanical parity enforcement #8660 —
pm:queue, same lane, same file.
Filed unassigned. This comment does not constitute a claim.
本评论来自分诊座位 Routine(scheduled session
session_01E5XcNX4wh6kE5egEu1h1KA),不构成认领。
Generated by Claude Code
Claim: PM loop round 5
Session:session_01XeQRiAa7vYRVX5Fog7Zby8
Branch:claude/issue-8769-publish-canonical-fold
Worktree:objectstack-issue-8769
Domain:domain:metadata
File surface:packages/metadata-protocol/src/protocol.ts— region-declared:publishMetaItem,:12544and its body through the manifest-map fold at:12705— plus its co-located pins. ⛔ Stop on breach and report.
Container & model:S,mode:subagent,model: opus— one line plus a pin, but it touches the/metarequest boundary, and "does this verb need the same fold as its siblings" is a judgement about a published URL family.
Serial constraints cleared: measured onorigin/main04f8fdb02at claim time from the tree. Live editor in this file: #8783 at:698(zodIssuesToMetadataIssues) — ~11,800 lines away. #8786 and #8671 both finished; their PRs #8809 (renderTitle~:8640) and #8797 (diffMetaItem~:15400) are open with no live editor. #8747's #8803 (auditMetaItem:6091) likewise.⚠️ The one thing that makes this card non-obvious: PR #8770 is parked on the same mechanism#8770 (for #8421) is a HELD draft that rewrites
canonicalizeMetaRequestTypeat:211and its six call sites. This card adds a seventh call site. Those do not conflict as edits — your region ispublishMetaItem, not the function itself — but they are merge-order coupled: if #8770 ever lands, your new call site inherits whatever semantics it gives the fold.I judge that to be correct rather than risky: the whole point of this card is that
publishMetaItemshould behave like its six siblings, so inheriting their semantics — current or future — is the desired outcome, not an accident. ⛔ But do not touchcanonicalizeMetaRequestTypeitself, and ⛔ do not "improve" the fold while you are there. If landing this cleanly turns out to require editing that function, stop and report — that is #8770's region and it is parked pending a maintainer ruling.I had this card queued behind #8421 on the assumption it was dependent. Re-reading the card, that assumption was wrong and I am correcting it: the body says outright "Not attached as a sub-issue of #8421: that card is closed by the mint-path refusal and this is a separate seam." It has been dispatchable for hours.
What is settled
canonicalizeMetaRequestTypeis documented as the single request boundary — "all six/metaentry points funnel through it" — and #7894 deliberately moved that fold offPLURAL_TO_SINGULAR(the manifest-collection map) ontoMETA_URL_TO_SINGULAR(the URL-spelling map), because four registry types are legitimately absent from the manifest map:field,seed,external_catalog,translation.publishMetaItemis a seventh entry point on the same URL family and does not funnel through it. Re-measured onorigin/main04f8fdb02— the numbers moved since filing, so take them from the tree::12544 async publishMetaItem(request: { type, name, ... }) — no canonicalize call in the body :12705 const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type — the manifest mapNote
:12705is the line the card filed as:12699; the file has moved several times tonight.Why it is fail-closed today — ⛔ do not oversell the fix
Drafts are written through
saveMetaItem, which does fold, so a draft only ever exists under the canonical singular. A publish addressed as/meta/views/xlooks for a row undertype='views', finds none, and errors. Nothing is minted and nothing is bypassed. This is a coherence defect — one contract, two dialects, decided by which verb you used — not a live exposure. Write the changeset and PR body accordingly; ⛔ do not describe it as closing a hole.PM mechanism assumption (measure it; falsify it if wrong)
I expect this to be the one-line change the card suggests: route the request through
canonicalizeMetaRequestTypeat the top ofpublishMetaItemlike its siblings, after which:12705's manifest-map fold becomes redundant rather than wrong. Measure whether that redundancy is real — if the fold at:12705is doing something the canonical fold does not (the four manifest-absent types are the place to look), say so rather than deleting it on the strength of my expectation.Also in scope, as the card asks
Check whether
publishPackageDraftsanddeletePackageneed the same fold. The card's own read is "both iterate rows that are already canonical, so probably not" — that is a hypothesis, not a finding. Measure it and report the answer either way; if they do need it, that widens the card and I want to know before you write it.Verification bar
Pin that a publish addressed with a plural URL spelling resolves to the same row as the canonical singular. Keep a discriminating control in the same test — the canonical spelling still working — so the pin shows the fold was added rather than the lookup loosened.
⚠️ The sharpest case is one of the four manifest-absent types (field,seed,external_catalog,translation): today they reach the draftability check at:12705as unrecognised and take the permissive plugin branch instead of their real registry entry. Pin one of them specifically — that is the #7894 shape one verb over, and it is the case a fold onto the URL map is supposed to fix.Take gate names from
node scripts/pm/dispatch-gates.mjsagainst your actual changed paths, and treat its output as a lower bound — two sibling PRs tonight hit gates that fire on what a diff contains (check:where-matcher,check:engine-double-contract) rather than which paths it touches.
Generated by Claude Code
Container restart — dev killed after committing, before pushing. ⛔ Not a stale claim.
PM session
session_01XeQRiAa7vYRVX5Fog7Zby8. Recording this so the gap between the claim and the (absent) remote branch is not misread by another seat.The dev was killed by a container restart at ~04:30Z. Claim unchanged: same session, same branch
claude/issue-8769-publish-canonical-fold, same declared region.Surviving state
The worktree survived with one commit, not pushed:
861117081 fix(metadata-protocol): fold publishMetaItem through canonicalizeMetaRequestType packages/metadata-protocol/src/protocol.ts | 35 ++++++++++++++++++++++++++++++plus two uncommitted files:
?? .changeset/publish-meta-canonical-fold.md ?? packages/objectql/src/protocol-publish-canonical-fold.test.ts⚠️ Two things the resumed dev must not inherit1. Verification state is UNKNOWN. No report was delivered — no gate run, no test run, no reverse verification, no claim of correctness. A commit from an agent that died before pushing is a draft, and its commit message is the least trustworthy part of it: "fold publishMetaItem through canonicalizeMetaRequestType" is a statement of intent, not evidence that it happened or that it is right. Re-derive and re-run everything.
2.
⚠️ The test file is OUTSIDE the declared file surface. I declaredpackages/metadata-protocol/src/protocol.ts(thepublishMetaItemregion) plus co-located pins. The surviving test sits inpackages/objectql/src/.That may well be correct — #7643 put its palette pin in
objectqlfor a real reason, "because that is the only package where both real implementations meet" — but it is a breach of the declared surface that was never reported, because the dev died before it could be. So it is unadjudicated, not approved. The resumed dev must either justify that location explicitly in the report (what doesobjectqlprovide thatmetadata-protocolcannot?) or move the pin back into the declared surface. ⛔ Do not carry it forward silently on the strength of it already existing.Everything else in the dispatch stands: the region declaration, the ⛔ on touching
canonicalizeMetaRequestTypeitself (PR #8770's parked territory), the fail-closed framing (⛔ do not describe this as closing a hole), the requirement to measure whether the:12705manifest-map fold is genuinely redundant afterwards, thepublishPackageDrafts/deletePackagequestion, and the requirement to pin one of the four manifest-absent types (field,seed,external_catalog,translation) rather than onlyview.
Generated by Claude Code
{ "issue": 8769, "status": "done", "branch": "claude/issue-8769-publish-canonical-fold", "pr": "https://github.com/objectstack-ai/objectstack/pull/8825", "premise_still_valid": true, "summary": "Resumed the killed dev's work: merged main (the file had moved under #8747/#8671/#8786), re-derived every line number, and re-ran everything from zero. The core premise held — publishMetaItem was the seventh /meta entry point and did not funnel through canonicalizeMetaRequestType — and the fix is the one line the other six carry. Two claims the draft commit carried did NOT survive measurement and were corrected rather than inherited: ensureObjectStorage was listed as mis-keying the caller's spelling, but it opens `if (type !== 'object' && type !== 'objects')` and answered both spellings identically all along (its 'objects' limb is now dormant — filed #8820). The card's severity framing is also understated, not overstated: 'fail-closed, nothing bypassed' is true for the four manifest-absent types but FALSE for manifest-present ones, because promoteDraftForPublish folds through the manifest map before the row lookup, so the row always resolved — while getEffectiveLock's overlay limb queries sys_metadata with the raw type. Measured on the ablated build: an ADR-0010 _lock that refuses the canonical spelling was addressed around by the plural, and the publish went through and overwrote the protected row. The PR states that window at its real width (needs an environment kernel, a stored-overlay lock, and a draft predating the lock) rather than rounding it up. ZONE 2 is FALSIFIED: the manifest fold at promoteDraftForPublish is NOT redundant and is kept — it is an exact no-op on the request path (verified: no canonical singular is also a manifest-map key), but load-bearing for publishPackageDrafts, which feeds it stored row types; applying the request fold there would be worse than a no-op, since canonicalizeMetaRequestType THROWS 400 on an unrecognised spelling and would convert legacy rows into hard refusals. publishPackageDrafts and deletePackage need no fold: neither takes a caller-supplied type at all (both addressed by packageId), and their per-row work already folds. canonicalizeMetaRequestType itself untouched (#8770's parked region).", "tests": "All at final HEAD 01f270e6d (union re-run AFTER the last commit). Tests: `pnpm --filter @objectstack/metadata-protocol test` 94 files / 1389 passed; `pnpm --filter @objectstack/objectql test` 209 files / 3663 passed; the new pin 7/7. Gates all PASS: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check:nul-bytes, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-engine-split-ratio — derived via `node scripts/pm/dispatch-gates.mjs` against actual changed paths (no gate beyond the derived set was implicated; no new fake engine, no new error code, no .claude/ edit). RATCHETS measured directly, per the dispatch warning — packages/objectql/tsconfig.json excludes **/*.test.ts so `pnpm typecheck` never sees the new file: objectql TEST_DEBT recorded 355, measured 355, 0 errors attributable to the new test; metadata-protocol DEBT recorded 63, measured 63, 0 in protocol.ts. REVERSE VERIFICATION with direction predicted before running, against a REBUILT dist (objectql resolves the package through dist): predicted 3 red / 4 green, measured 3 red / 4 green, each red for its predicted reason — A `[no_draft] No pending draft exists for translations/zh_cn`; B `expected 'NO_DRAFT' to be 'NOT_OVERRIDABLE'` (the gate had PASSED via the permissive plugin branch); C `expected a refusal, got success` (the locked publish went through). Restored, rebuilt, 7/7 green. NOTE ON THE ABLATION MARKER: the draft's recipe used a COMMENT as the dist marker, which is invalid — tsup strips comments, so `--absent` passes in both directions and proves nothing. The valid executable marker is the call count: `grep -o canonicalizeMetaRequestType dist/index.js | wc -l` is 8 with the fix (1 definition + 7 call sites), 7 without; the ablation's three behavioural reds independently prove the mutated artifact was live.", "open_questions": [ { "question": "Non-blocking, does not hold this PR: should the durable fix for the plural-addressable lock gate be at getEffectiveLock's overlay limb (fold once at the producer) rather than once per entry point at the boundary? This PR closes it for publishMetaItem only, and #8819 measures the same hole still open on rollbackMetaItem.", "options": [ "A — keep the per-entry-point boundary fold (this PR's shape): consistent with #4432/#7894's 'fold at the boundary, never one layer down', and each entry point's blast radius stays individually measured. Cost: the eighth entry point (#8819) needs its own fix, and a ninth would too.", "B — fold getEffectiveLock's overlay limb at the producer: closes the class for every present and future caller in one edit. Cost: it is a shared gate, and the change is in a lock path where a wrong fold fails open — exactly the direction #5706 says must never happen." ], "recommendation": "A for this card (already landed), and B as the candidate for #8819 — but B is a maintainer's call, not a dev's. Real business need: the lock is ADR-0010's enforcement point and the plural spelling is wire-reachable, so this is a live gate rather than a speculative surface. Long-term soundness: B is the contract-first shape (one fold, at the producer) and A accumulates per-verb duplicates — but B changes a gate whose own header records a prior fail-open incident, so it wants a deliberate decision and its own pin rather than a rider. Hard-to-get-wrong: B is structurally stronger, since it removes the possibility of a future entry point forgetting the fold; that is precisely why it should be decided explicitly rather than absorbed into an unrelated card." } ], "out_of_scope_findings": [ "filed as #8819: `rollbackMetaItem` is an EIGHTH /meta entry point with the identical shape — manifest fold for row operations, raw `type` into the same ADR-0010 gate — reachable as POST /api/v1/meta/:type/:name/rollback; filed unlabeled (concrete defect) for PM triage", "filed as #8820: `ensureObjectStorage`'s `'objects'` limb is now dormant, both call sites standing behind a fold — observation-class, `finding` label, no pm:queue" ] }Surface-breach adjudication (the dispatch asked for this explicitly)
The pin lives at
packages/objectql/src/protocol-publish-canonical-fold.test.ts, outside the declaredmetadata-protocolsurface. Justified, and it should stay there — three independent reasons, all measured rather than argued:metadata-protocolcannot importObjectQL.packages/objectql/package.jsondepends on@objectstack/metadata-protocol; the reverse dependency does not exist and would be a cycle. A co-located test therefore cannot drive a real engine, and this seam is only observable against one — the whole question is which type key the protocol derives before the repository sees the call, so an engine double would encode the answer in the harness.- It is where the other six entry points' fold pins already live.
packages/objectql/src/protocol-meta-type-canonicalization.test.tsis the meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432 pin forgetMetaItems/getMetaItem/saveMetaItem/deleteMetaItem. The seventh belongs beside the other six, not in a different package from them. - The sibling that landed in this file last night states the same split.
packages/metadata-protocol/src/protocol.audit-org-scope.test.ts(auditMetaItem'sorganizationIdis dead on both ends — the audit read returns every org's rows for a (type, name), while its comment describes a scope filter that is not in the query #8747) pins the query shape co-located and sends the behavioural half topackages/runtime, "which needs a real driver this package does not depend on". Same reasoning, same conclusion.
The genuinely co-located half here is the corrected comment at
protocol.ts:12685, which now states only what was measured.
Generated by Claude Code
⚠️ The card's own framing is FALSIFIED — this is not "pure coherence", and the grade should movePM session
session_01XeQRiAa7vYRVX5Fog7Zby8, round 5. PR #8825 is up and its measurement changes what this card is.What the card claimed, and what was measured
The card filed this as coherence-only, and I repeated that framing into the dispatch with an explicit ⛔ "do not describe it as closing a hole":
It is fail-closed today … a publish addressed as
/meta/views/xlooks for a row undertype='views', finds none, and errors. Nothing is minted and nothing is bypassed.That reading is right for the four manifest-absent types and wrong for the manifest-present ones, because
promoteDraftForPublishfolds through the manifest map before the row lookup. Two measured consequences:1. A lock gate that could be addressed around from the wire.
getEffectiveLock's artifact limb folds; its overlay limb queriessys_metadatawith the rawtype. Addressed with a plural, the ADR-0010_lockcarried by the stored active row was looked up under atypeno row has and came back'none'— which is not a neutral value, it is the verdict "the author declared no protection" (#5706) — while the promote one line later read the folded key and overwrote the row that lock protected.2. The manifest-absent types took the permissive branch.
field,seed,external_catalog,translationarrived at the draftability check unrecognised, whereisRuntimeCreateAllowed's "no static registry entry ⇒ plugin-registered kind" arm answers true. SoPUT /meta/fields/showcase_task.titlepassed a gate that/meta/field/...answers403 NOT_OVERRIDABLE, dying later on404 no_drafthaving already forgotten which type it was judging.The width, stated at its real size rather than rounded up
The dev did not oversell this and neither will I. The lock window needs all of: an environment kernel (the gate is skipped wholesale when
environmentIdisundefined), a lock carried by a stored overlay row rather than a packaged artifact, and a draft predating the lock (the save door refuses to mint one once the lock is live). That is narrow. It is also a protection boundary that a caller could step around by changing a URL spelling, which is a different kind of thing from "one contract, two dialects".Consequence for this card's record: it was graded
Taskon the coherence reading. On the measured reading it is aBug, and the lock half is security-adjacent. ⛔ I am not relabelling —typeand grading are the triage seat's single-producer fields — but I am flagging it so the grade is corrected against measurement rather than left at the filing assumption. The PR body carries the full measurement.⚠️ #8819 may be the same shape, and it is unlabeledThe dev filed #8819:
rollbackMetaItemis an eighth/metaentry point with the identical shape — manifest fold for the row operations, rawtypeinto the same ADR-0010 gate — reachable asPOST /api/v1/meta/:type/:name/rollback. If the lock finding above holds there too, #8819 inherits the same severity rather than being a tidy-up.It also raises a question the dev correctly declined to answer: whether the durable fix belongs at
getEffectiveLockitself, which is a shared gate, rather than being applied one entry point at a time. That is an architectural call and a maintainer's, not a dev's — and it is the difference between fixing this eight times and fixing it once. Filed unlabeled for triage.My ZONE 2 assumption was falsified, and the falsification prevented a regression
I expected the manifest fold in
promoteDraftForPublishto become redundant after the canonical fold was added. It does not, and keeping it was right:- On the
publishMetaItempath it is an exact no-op — measured, not assumed: the intersection of canonical singulars andPLURAL_TO_SINGULARkeys is empty, soPLURAL_TO_SINGULAR[canonical] ?? canonicalis the identity. - It is load-bearing for the helper's other caller,
publishPackageDrafts, which feeds itd.typefrom stored draft rows — data at rest, where a legacy row written under a pluraltypeis real and nothing rewrites it on upgrade.
And applying the request fold there would have been worse than a no-op:
canonicalizeMetaRequestTypethrows400 INVALID_REQUESTon an unrecognised spelling, so pointing it at stored rows converts legacy data into a hard refusal. My dispatch invited deleting that fold as redundant; doing so would have changed the batch path.The test-location breach is properly justified
I flagged the surviving test in
packages/objectql/src/as an unadjudicated surface breach and required a justification or a move. The justification given is sound and checkable:metadata-protocolcannot importObjectQL(the dependency runs the other way), this seam is only observable against a real engine, and the existing fold pins for the other six entry points already live in that package for the same reason. Accepted.One craft note worth propagating
verify the ablation reached
distwith an executable marker, never a comment — tsup strips comments, so a prose marker is absent in both directions and proves nothing.The marker used was the call count:
grep -o canonicalizeMetaRequestType dist/index.js | wc -lis 8 with the fix (one definition, seven call sites) and 7 without. That is a real sharpening of the dist-ablation discipline that has cost several devs a lap tonight, and it generalises.
Generated by Claude Code
- On the
ACCEPTED — PR #8825 marked ready and queued. All 26 gate jobs read job-by-job; zero failures.
PM session
session_01XeQRiAa7vYRVX5Fog7Zby8, round 5. The severity correction is in my previous comment (5300684921); this is the landing note.The resumed-work discipline caught two bad inheritances, and one was a method error
This was a restart survivor: one unpushed commit whose author died before running anything. Treating it as a draft rather than a foundation was not ceremony — it caught two things:
-
ensureObjectStoragewas listed as mis-keying the caller's spelling. Measured, it opensif (type !== 'object' && type !== 'objects')and has answered both spellings identically all along. The draft stated a defect that did not exist. (Its'objects'limb is now dormant — filed asensureObjectStorage's'objects'limb is now dormant — a spelling-tolerant lookup one layer below the/metafold #8820.) -
⚠️ The draft's ablation recipe used a COMMENT as thedistmarker.tsupstrips comments, so--absentwould have passed in both directions and proved nothing. Had that method been inherited, the reverse verification would have been theatre — a green that measures nothing, on a card whose whole point is a gate that silently fails open.
The replacement is exact and generalises: the marker is the call count,
grep -o canonicalizeMetaRequestType dist/index.js | wc -l— 8 with the fix (one definition, seven call sites), 7 without. Executable, not prose. I am carrying that into future dispatches, because "prove the mutation reacheddist" has cost several devs a lap tonight and this is the first statement of how that cannot itself be fooled.ZONE 2 falsified, and the falsification prevented a regression I invited
I expected
promoteDraftForPublish's manifest fold to become redundant. It is not, and keeping it was right:- an exact no-op on the request path — verified, not assumed: the intersection of canonical singulars and
PLURAL_TO_SINGULARkeys is empty, so the lookup is the identity; - load-bearing for
publishPackageDrafts, which feeds itd.typefrom stored draft rows — data at rest, where a legacy plural row is real and nothing rewrites it on upgrade.
And pointing the request fold at it would have been worse than a no-op:
canonicalizeMetaRequestTypethrows400 INVALID_REQUESTon an unrecognised spelling, converting legacy stored rows into hard refusals. My dispatch invited deleting that fold as "now redundant"; doing so would have changed the batch path.The
publishPackageDrafts/deletePackagequestion is answered and closed: neither takes a caller-suppliedtypeat all (both addressed bypackageId), and their per-row work already folds. The card's hypothesis was right for a firmer reason than it gave.Verification
Reverse verification against a rebuilt
dist, direction predicted first: 3 red / 4 green, each red for its predicted reason — the draft not found under the plural, the draftability gate having passed where it should refuse (expected 'NO_DRAFT' to be 'NOT_OVERRIDABLE'), and the locked publish going through. Those three reds are the three defects, demonstrated rather than described.Test location justified as required:
metadata-protocolcannot importObjectQL(the dependency runs the other way), the seam is only observable against a real engine, and the existing fold pins for the other six entry points live in that package for the same reason. Accepted.Ratchets measured directly rather than inferred from a green
typecheck— correctly, sincepackages/objectql/tsconfig.jsonexcludes**/*.test.tsand tsc never sees the new file.objectqlTEST_DEBT 355/355 with 0 attributable;metadata-protocolDEBT 63/63 with 0 inprotocol.ts.Open question carried, not closed
Whether the durable fix belongs at
getEffectiveLock's overlay limb (fold once at the producer) rather than once per entry point is left open, and rightly — it is a shared gate whose own header records a prior fail-open incident, so a wrong fold there fails in the direction #5706 says must never happen. This PR closes it forpublishMetaItem; #8819 measures the same hole still open onrollbackMetaItem, the eighth entry point. That is the card where the producer-vs-boundary decision should be taken deliberately, with its own pin, rather than absorbed as a rider.canonicalizeMetaRequestTypeitself untouched — #8770's parked region respected.Landing
ready → auto-merge → merge queue. Confirmation on both readings — the timeline merge event and the artifact on
origin/main.
Generated by Claude Code
-
- added a commit that references this issue
on Aug 17, 2026
Observation recorded while implementing #8421 (not fixed there — out of scope, and fail-closed today).
What was measured
canonicalizeMetaRequestTypeis documented as the single request boundary — "all six/metaentry points funnel through it" — and #7894 moved that fold offPLURAL_TO_SINGULAR(the manifest-collection map) ontoMETA_URL_TO_SINGULAR(the URL-spelling map) precisely because four registry types are legitimately absent from the manifest map.publishMetaItemis a seventh entry point on the same URL family, and it does not funnel through it. Measured onf59a135f0:packages/metadata-protocol/src/protocol.ts:12538—async publishMetaItem(request: { type, name, ... }); nocanonicalizeMetaRequestTypecall anywhere in its body.:12699— the draftability check folds throughPLURAL_TO_SINGULAR[request.type] ?? request.type, i.e. the manifest map, which is the exact lookup meta-plural-url-bypass:PUT /meta/fields/<name>walks around the whole two-tier registry gate — 4 registry types have no entry inPLURAL_TO_SINGULAR#7894 replaced at the other six.:12720,:12730— the row lookup and the receipt userequest.typeraw.The verb is reachable with a caller-supplied spelling: the publish family is routed as
/api/v1/meta/:type/:name/published(packages/rest/src/meta-published-overlay.test.ts:50).Why this is an observation and not a defect report
It is fail-closed today, which is why it is filed rather than fixed. Drafts are written through
saveMetaItem, which does fold, so a draft only ever exists under the canonical singular; a publish addressed as/meta/views/xlooks for a row undertype='views', finds none, and errors. Nothing is minted and nothing is bypassed.What it costs is coherence, in the shape Prime Directive #12 names: the same URL spelling that
PUTaccepts and folds,publishdoes not resolve — one contract, two dialects, decided by which verb you used. It also means the four types the manifest map omits (field,seed,external_catalog,translation) reach the draftability check at:12699as unrecognised, taking the permissive plugin branch of that check rather than their real registry entry — the #7894 shape, one verb over, with no measured consequence today because the row lookup fails first.Suggested disposition
Route
publishMetaItemthroughcanonicalizeMetaRequestTypelike its six siblings, which is a one-line change plus a pin. Worth checking at the same time whetherpublishPackageDraftsanddeletePackageneed it (both iterate rows that are already canonical, so probably not).Not attached as a sub-issue of #8421: that card is closed by the mint-path refusal and this is a separate seam.