Skip to content

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

@hotlong

Observation recorded while implementing #8421 (not fixed there — out of scope, and fail-closed today).

What was measured

canonicalizeMetaRequestType is documented as the single request boundary — "all six /meta entry points funnel through it" — and #7894 moved that fold off PLURAL_TO_SINGULAR (the manifest-collection map) onto META_URL_TO_SINGULAR (the URL-spelling map) precisely because four registry types are legitimately absent from the manifest map.

publishMetaItem is a seventh entry point on the same URL family, and it does not funnel through it. Measured on f59a135f0:

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/x looks for a row under type='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 PUT accepts and folds, publish does 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 :12699 as 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 publishMetaItem through canonicalizeMetaRequestType like its six siblings, which is a one-line change plus a pin. Worth checking at the same time whether publishPackageDrafts and deletePackage need 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.

Activity

  1. added theissue type on Aug 14, 2026
  2. hotlong commented on Aug 14, 2026

    @hotlong
    ContributorAuthor

    Triage: promoted finding → pm:queue, routed domain:metadata, type Task.

    Routing (read the code, not the title): the fix lands in packages/metadata-protocol/src/protocol.ts, which is packages/metadata* ⇒ domain:metadata by the anchoring rule. Same lane as its sibling seam #8421. ⛔ Not domain:spec — no schema, accept-set or packages/spec surface changes; the seam is a request-canonicalization call site.

    Premise re-verified independently on origin/main @ ab8b10f (not inherited — the card measured f59a135f0, a PR branch, so its line numbers differ):

    Not absorbed by the in-flight PR. Checked #8770's changed files directly: it adds opts.minting and passes { minting: true } from saveMetaItem only. publishMetaItem is 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/published is not documented at all in content/docs/api/metadata-api.mdx (verified: the file documents GET /meta, GET /meta/:type, GET /meta/:type/:name, PUT /meta/:type/:name and the /packages family — no published entry). So the "singular or plural address the same type" declaration at :26 is 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:

    1. 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). publishMetaItem promotes an already-minted draft, so the plain fold canonicalizeMetaRequestType(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.
    2. The four manifest-absent types are the real scope risk. field, seed, external_catalog, translation reach the :12646 draftability 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):

    Filed unassigned. This comment does not constitute a claim.

    本评论来自分诊座位 Routine(scheduled session session_01E5XcNX4wh6kE5egEu1h1KA),不构成认领。


    Generated by Claude Code

  3. self-assigned this
    on Aug 15, 2026
  4. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    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, :12544 and 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 /meta request boundary, and "does this verb need the same fold as its siblings" is a judgement about a published URL family.
    Serial constraints cleared: measured on origin/main 04f8fdb02 at 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 canonicalizeMetaRequestType at :211 and its six call sites. This card adds a seventh call site. Those do not conflict as edits — your region is publishMetaItem, 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 publishMetaItem should behave like its six siblings, so inheriting their semantics — current or future — is the desired outcome, not an accident. ⛔ But do not touch canonicalizeMetaRequestType itself, 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

    canonicalizeMetaRequestType is documented as the single request boundary — "all six /meta entry points funnel through it" — and #7894 deliberately moved that fold off PLURAL_TO_SINGULAR (the manifest-collection map) onto META_URL_TO_SINGULAR (the URL-spelling map), because four registry types are legitimately absent from the manifest map: field, seed, external_catalog, translation.

    publishMetaItem is a seventh entry point on the same URL family and does not funnel through it. Re-measured on origin/main 04f8fdb02 — 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 map
    

    Note :12705 is 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/x looks for a row under type='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 canonicalizeMetaRequestType at the top of publishMetaItem like its siblings, after which :12705's manifest-map fold becomes redundant rather than wrong. Measure whether that redundancy is real — if the fold at :12705 is 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 publishPackageDrafts and deletePackage need 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 :12705 as 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.mjs against 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

  5. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    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 inherit

    1. 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 declared packages/metadata-protocol/src/protocol.ts (the publishMetaItem region) plus co-located pins. The surviving test sits in packages/objectql/src/.

    That may well be correct — #7643 put its palette pin in objectql for 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 does objectql provide that metadata-protocol cannot?) 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 canonicalizeMetaRequestType itself (PR #8770's parked territory), the fail-closed framing (⛔ do not describe this as closing a hole), the requirement to measure whether the :12705 manifest-map fold is genuinely redundant afterwards, the publishPackageDrafts / deletePackage question, and the requirement to pin one of the four manifest-absent types (field, seed, external_catalog, translation) rather than only view.


    Generated by Claude Code

  6. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor
    {
      "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 declared metadata-protocol surface. Justified, and it should stay there — three independent reasons, all measured rather than argued:

    1. metadata-protocol cannot import ObjectQL. packages/objectql/package.json depends 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.
    2. It is where the other six entry points' fold pins already live. packages/objectql/src/protocol-meta-type-canonicalization.test.ts is the meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432 pin for getMetaItems / getMetaItem / saveMetaItem / deleteMetaItem. The seventh belongs beside the other six, not in a different package from them.
    3. The sibling that landed in this file last night states the same split. packages/metadata-protocol/src/protocol.audit-org-scope.test.ts (auditMetaItem's organizationId is 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 to packages/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

  7. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    ⚠️ The card's own framing is FALSIFIED — this is not "pure coherence", and the grade should move

    PM 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/x looks for a row under type='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 promoteDraftForPublish folds 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 queries sys_metadata with the raw type. Addressed with a plural, the ADR-0010 _lock carried by the stored active row was looked up under a type no 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, translation arrived at the draftability check unrecognised, where isRuntimeCreateAllowed's "no static registry entry ⇒ plugin-registered kind" arm answers true. So PUT /meta/fields/showcase_task.title passed a gate that /meta/field/... answers 403 NOT_OVERRIDABLE, dying later on 404 no_draft having 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 environmentId is undefined), 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 Task on the coherence reading. On the measured reading it is a Bug, and the lock half is security-adjacent. ⛔ I am not relabelling — type and 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 unlabeled

    The dev filed #8819: rollbackMetaItem is an eighth /meta entry point with the identical shape — manifest fold for the row operations, raw type into the same ADR-0010 gate — reachable as POST /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 getEffectiveLock itself, 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 promoteDraftForPublish to become redundant after the canonical fold was added. It does not, and keeping it was right:

    • On the publishMetaItem path it is an exact no-op — measured, not assumed: the intersection of canonical singulars and PLURAL_TO_SINGULAR keys is empty, so PLURAL_TO_SINGULAR[canonical] ?? canonical is the identity.
    • It is load-bearing for the helper's other caller, publishPackageDrafts, which feeds it d.type from stored draft rows — data at rest, where a legacy row written under a plural type is real and nothing rewrites it on upgrade.

    And applying the request fold there would have been worse than a no-op: canonicalizeMetaRequestType throws 400 INVALID_REQUEST on 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-protocol cannot import ObjectQL (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 dist with 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 -l is 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

  8. hotlong commented on Aug 15, 2026

    @hotlong
    ContributorAuthor

    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:

    1. ensureObjectStorage was listed as mis-keying the caller's spelling. Measured, it opens if (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 as ensureObjectStorage's 'objects' limb is now dormant — a spelling-tolerant lookup one layer below the /meta fold #8820.)

    2. ⚠️ The draft's ablation recipe used a COMMENT as the dist marker. tsup strips comments, so --absent would 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 reached dist" 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_SINGULAR keys is empty, so the lookup is the identity;
    • load-bearing for publishPackageDrafts, which feeds it d.type from 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: canonicalizeMetaRequestType throws 400 INVALID_REQUEST on 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 / deletePackage question is answered and closed: neither takes a caller-supplied type at all (both addressed by packageId), 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-protocol cannot import ObjectQL (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, since packages/objectql/tsconfig.json excludes **/*.test.ts and tsc never sees the new file. objectql TEST_DEBT 355/355 with 0 attributable; metadata-protocol DEBT 63/63 with 0 in protocol.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 for publishMetaItem; #8819 measures the same hole still open on rollbackMetaItem, 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.

    canonicalizeMetaRequestType itself 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions