Repository navigation
fix(metadata): route the three read-side /meta verbs through the canonical type boundary (#9157) - #9191
Conversation
…cal /meta type boundary (#9157) auditMetaItem, historyMetaItem and findReferencesToMeta derived their type key from PLURAL_TO_SINGULAR - the manifest-collection map #7894 moved this boundary off - instead of calling canonicalizeMetaRequestType, which nine sibling verbs already call. That call carries both the URL map and metaUrlSpellingRefusal, and the refusal is the half those three could never reach: it lives inside the function they skipped. Step 1 of the #9180 maintainer ruling (the /meta type segment is singular, always). Steps 2 and 3 stay unbundled and independently revertible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…d-verb-canonical-fold
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…d-verb-canonical-fold
…ent, not package edges The advisory resolved pages by PACKAGE DEPENDENCY -- "which hand-written docs reference @objectstack/x". That is a dependency-graph proxy answering a semantic question, and #9192 measured it wrong in BOTH directions on PR #9191 (the three metadata read verbs): 3 pages listed of which 1 was relevant, while the 2 pages that actually document the changed surface were absent, because they document it through the SDK -- which does not depend on the implementing package at all. A page is now listed when it NAMES something the change touched. Three anchor kinds: the documentable declaration enclosing each changed line (top-level, or a member of a top-level container -- a local inside a function body is not surface, which is what drops the measured `singular` false positive); the wire routes a changed symbol's registrar handler serves; and the client method the route ledgers bind to those routes, which is the hop that carries the derivation across the surface boundary the package graph cannot cross. Two guards keep it precision-first, both running BEFORE the route bridge so a weak name cannot mint route anchors from every handler that mentions it: a shape guard (a single lowercase word like `label` or `object` matched 82 and 113 of 178 pages) and a corpus-share guard (`ObjectQL` is code-shaped and genuinely changed, and names 59 of 178 pages). Everything either guard removes is published, as is every changed file that yielded no anchor -- silence from this tool must never be readable as absence, which is the whole subject of the card. Measured before/after across ten real PRs; the PR comment now also states what the run could not see, at the point of use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
契约复审(needs:contract-review 清标签)— 通过复审席:skills 席 session_017TNzEetykdh7ceZGwuAPLq(契约复审档位,非派发席,过渡期代行)。范围:契约行为增量全文(protocol.ts 三处折叠接入 + 测试断言清单 + changeset 的 wire 变更表)。 结论:
Generated by Claude Code |
…ent, not package edges (objectstack-ai#9229) The advisory resolved pages by PACKAGE DEPENDENCY -- "which hand-written docs reference @objectstack/x". That is a dependency-graph proxy answering a semantic question, and objectstack-ai#9192 measured it wrong in BOTH directions on PR objectstack-ai#9191 (the three metadata read verbs): 3 pages listed of which 1 was relevant, while the 2 pages that actually document the changed surface were absent, because they document it through the SDK -- which does not depend on the implementing package at all. A page is now listed when it NAMES something the change touched. Three anchor kinds: the documentable declaration enclosing each changed line (top-level, or a member of a top-level container -- a local inside a function body is not surface, which is what drops the measured `singular` false positive); the wire routes a changed symbol's registrar handler serves; and the client method the route ledgers bind to those routes, which is the hop that carries the derivation across the surface boundary the package graph cannot cross. Two guards keep it precision-first, both running BEFORE the route bridge so a weak name cannot mint route anchors from every handler that mentions it: a shape guard (a single lowercase word like `label` or `object` matched 82 and 113 of 178 pages) and a corpus-share guard (`ObjectQL` is code-shaped and genuinely changed, and names 59 of 178 pages). Everything either guard removes is published, as is every changed file that yielded no anchor -- silence from this tool must never be readable as absence, which is the whole subject of the card. Measured before/after across ten real PRs; the PR comment now also states what the run could not see, at the point of use. Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza Co-authored-by: Claude <noreply@anthropic.com>
… that has one (objectstack-ai#18251) Fixes objectstack-ai#18174 `check-clause2-carriers --pair` read a review of record's `Served-tier:` line only beside a gate clear, so `references/contract-review.md` :29's 「无此行不成裁决」 was enforced on one path of two. ## The defect, measured One spelling, two answers, same script, same day: | pair | gate hung? | the record's `Served-tier:` | `--pair` | |---|---|---|---| | objectstack PR objectstack-ai#18157 / card objectstack-ai#17991 (record 5661052272) | no — `Clause-②` never declared `yes` | a stamp control, then prose, then the constant — unreadable to this file's own reader | **0** | | objectui PR objectstack-ai#9486 / card objectstack-ai#9191 (record 5662548425) | hung and cleared | the same spelling | **4**, C7 | The seam: `reviewOfRecord` answered `not-owed` before reading a thread unless the pair was in C6's completed state (`needsRecordRead` → `gateBindingState(pair).state === 'completed'`), and `c7ServedTierBelow` returned `null` for anything but `found`. Measured on this branch's base `b3b43b6`: a non-gated pair carrying an unreadable record, one carrying the live shape, and one carrying no record at all were indistinguishable — `pairRows` empty for all three, `reviewOfRecord` `not-owed` for all three. ## The change - **`locateReviewOfRecord(pair)`** — the gate-independent locator: the same imported H51 heading / head-sha recognition, the same `Reviewed-by:` third fact, the same newest-governs choice, with no reference to the gate. No recognition moved. - **`reviewOfRecord(pair)`** — that locator under C6's population gate. C6's row, its 「not-owed」 scope, its shape sentence and its remedy are unmoved, and both rows still read ONE comment chosen once. - **C7** and the **C6-RECORD note** read the locator. C7's accept set (one token, the constant's NAME), its exactness, its refusal of a missing line and its remedy are untouched — widened in POPULATION only, in the direction its own rule text names. Its opening sentence is now gate-aware: it names a clear only where a clear rides on the record. The note's citation half stays C6's, because the act it names is C6's. - **`gather`'s `landingReads`** (was `readFiles`) buys the PR thread for every pair on the `--pair` path, where the ruling puts this reading (「清标前」); the sweep's population is unchanged, for the budget reason C5's identical split already records. **`locatedRecordUnjudged`** is that path's own objectstack-ai#4690 half, so an unread thread is UNJUDGED and never clean. - **Consequence pinned, not left to be discovered:** with the PR thread in hand, `verdictThreadRows` hands C4 a thread it previously saw only on completed pairs, so a verdict on a non-gated or still-hung pair is now judged for the independence pair it declares. That direction only adds verdicts to a reading whose newest-governs rule already lets a later independent verdict displace an older self-review. Pinned in both directions; the sweep's blindness there is restated as the limit that survives. ## Evidence **Reproduction on the real historical pair, replayed offline.** Both PRs are merged, so `--pair` cannot form the pair from the live board; the document carries the fetched `/pulls/18157`, `objectstack-ai/issues/17991`, both comment threads and the file list, and nothing else. - before (`origin/main` `b3b43b6`): `--pair 18157 --pair-json …` → exit **0** - after: exit **4** — C7 naming comment 5661052272, the unreadable line quoted back, and its stamp control read as NOT total (the 「回退证据」 half) **Live control**, PR objectstack-ai#18243 / card objectstack-ai#18229 — open, non-gated, `Clause-②: no`, record 5673963268 in the template's shape: exit **0** before and after. After, the run also prints the C6-RECORD note naming that record and saying the pair owes no clear (4 reads → 5). **The sweep is unmoved, deterministically:** the same document in sweep mode produces byte-identical `--json` output before and after. A live gated pair (objectstack-ai#18212) reads exit 4 with the same rows on both. **Self-test:** 629 → 658 cases, `pnpm check:pm-clause2-carriers` exit 0. A new battery (`objectstack-ai#18174: …`, 28 cases) carries the measured non-gated spelling beside the live one; the roster floor moves 22 → 23 and C7's own floor 42 → 43. Four of C7's population pins were re-triaged rather than left green for the wrong reason: each now spells a pair whose threads were READ and carry no record, instead of passing because no thread was ever fetched. **Reverse verification**, from the committed fix: `c7ServedTierBelow`'s locator call mutated back to `reviewOfRecord`. On-disk proof — injected marker grep = 1, deleted anchor grep = 0, blob hash `5a79776b` → `c4757e7a`. Self-test under the ablation: **5 of 658 red** (the measured pair, its two sentence pins, the still-hung pair and the 重挂 pair). Restored with `git checkout HEAD --`: `git diff HEAD` empty and the blob back at `5a79776b`. Direction: turns red, as predicted. **Gates**, union re-run at `7af15586` (`git rev-parse --short HEAD`), working tree clean: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derives **35** families; all 35 ran in the foreground with exits captured by redirect, all **exit 0**; `--ran` reconciles **35 derived / 35 run / 0 NOT-MEASURED / 0 UNRUN**, every exit code recorded. Beyond the derivation: `pnpm lint` (repo-wide, `eslint . --no-inline-config`) exit 0, and the two siblings that import this module — `check-governed-queue-guard.mjs --self-test` (238 cases) and `check-half-states.mjs --self-test` (4042 cases) — exit 0. `skip-changeset`: `scripts/pm/**` is repo tooling that no package `files[]` ships. ## Acceptance notes - noted, not filed: in a SWEEP a record on a non-gated pair stays invisible, because the sweep buys no PR thread for it. That is a declared limit, written into this file's budget paragraph beside C5's identical one, not a defect — and 承接者: nobody today; it becomes a card only if a seat ever prices a per-pair sweep thread. - noted, not filed: an existing pre-fetched `--pair-json` document that omits the `comments` entry for the PR now reads UNJUDGED (exit 2) on a pair that owes no record, where it read 0 before. That is this change's own documented owing, restated in the doc-shape paragraph, not a separate finding. 承接者: the MCP-only seats that hand this file a document — the file's own refusal text names the key to add. - The C4 reach on the landing path widens as a consequence of the bought thread (see above). In scope, documented and pinned in both directions. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #9157
Step ① of the maintainer ruling in #9180 (2026-08-16): the
/metatype segment is singular, always, no exception.auditMetaItem,historyMetaItemandfindReferencesToMetaeach opened by deriving their type key fromPLURAL_TO_SINGULAR— the MANIFEST-COLLECTION map #7894 moved this boundary off — instead of callingcanonicalizeMetaRequestType, which the nine sibling/metaverbs already call. That one call carries both the URL spelling map andmetaUrlSpellingRefusal, and the refusal is the half these three could never reach: it lives inside the function they skipped.⛔ Steps ② (the
/meta/objects/:name/state/:fieldroute rename) and ③ (docs + retiringPLURAL_TO_SINGULARas a fold) are not in this PR. #9180 requires the three steps to stay independently revertible.#9174remains open and is not addressed here.The filing card asserted that
PUT /meta/views/all_leadsis "refused 400 by the same deployment for the same spelling", and the dispatch summarised the deliverable as three GET routes going "200-empty → 400". The first is false and the second is true only for one of two classes. Measured onorigin/mainbefore writing any code:viewsis inMETA_URL_TO_SINGULAR, so it folds — on the write verbs today just as on the read verbs after this PR. The refusal class is misspellings of a declared type, not recognised plurals. The premise still stands where it matters (three verbs genuinely skip the boundary, and the empty-accumulator harm is real), so this is a correction rather than a falsification — but the delivered wire change is two-shaped, not one::typeviewes— unrecognised spelling of a declared typeINVALID_REQUEST, namingviewandviewstranslations/fields/seeds/external_catalogs— recognised plurals of the four manifest-ABSENT typesviews— recognised plural, manifest-PRESENTfieldz— reaches for no declared typeBoth rows close the harm #9180 named — "nothing depends on this" delivered right before a rename or delete. One does it by refusing loudly; the other by finally answering truthfully. Retiring the recognised plurals themselves is step ③, and a pin (
theRecognisedPluralsAreNotRefused) asserts they are still accepted so this PR cannot be read as having bundled it.⭐ The population, re-derived rather than taken from the card
The card hand-listed "nine fold, three do not". Derived mechanically from the source instead — every class method taking a request with a required
type: string, which is the shape a/meta/:typepath segment arrives as:getMetaItems,getMetaItem,getMetaItemLayered,getMetaItemCached,saveMetaItem,publishMetaItem,rollbackMetaItem,diffMetaItem,deleteMetaItemauditMetaItem,historyMetaItem,findReferencesToMetaThe count is three; the card was right. The derivation is now a test, so a tenth verb arriving unfolded goes red rather than being noticed a card later.
Three methods were deliberately excluded and the exclusion is stated so it can be contested:
getMetaDiagnostics,listDrafts(type?:) andmigrateStoredMetadata(types?: string[]) take the type as a query-string filter, not a path segment — a different contract, andmigrateStoredMetadatadocuments accepting both spellings on purpose.Two measured details that invert an intuition
historyMetaItemthe plural was a door AROUND a gate, not merely a wrong key.fielddeclares neitherallowOrgOverridenorallowRuntimeCreate, so the canonical spelling is refused by the overlay gate and never touches the store — whilefieldstookisRuntimeCreateAllowed's no-static-registry-entry arm (the plugin path, permissive by construction) and issued a realsys_metadata_historyread keyed'fields'. Same empty body, opposite path, which is why the pins assert the KEY the store was asked for and not only the body.findReferencesToMetathe refusal is the whole visible change. EveryREFERENCE_PATHSkey (object,view,tool,skill,flow,dashboard,page) is manifest-PRESENT and already folded, so a manifest-absent target still answers{ references: [] }after this PR. That residue is asserted rather than implied, and filed separately asfindReferencesToMetaanswers{references: []}for every target type absent fromREFERENCE_PATHS— the "Used by" panel is silent by construction for 20+ declared types #9190 (finding, unassigned) — it is a registry-coverage question, not a spelling one.Reverse verification
Direction predicted in writing before running: plain RED (not "more diagnostics", not inverted — the refusal is thrown by the boundary itself, not by a downstream schema, so there is no
??-chain fall-through to reroute it).Observed: plain RED, exactly as predicted. Ablating the three fold calls back to
PLURAL_TO_SINGULAR[request.type] ?? request.typeand re-running the pin file:Every predicted-green control stayed green in both directions: the anti-vacuity arm,
theRecognisedPluralsAreNotRefused, all four manifest-PRESENT / plugin-shaped controls, and the honestfindReferencesToMetascope pin. The fix was committed before the ablation, so restoring it wasgit checkout <branch> -- <path>andgit statuscame back clean — the restored tree is byte-identical to the committed one.Docs
No documentation change is needed, and that was established by sweeping the whole
content/docs/**corpus rather than a supplied page list./history,/auditand/referencesappear in exactly two pages, neither of which makes a spelling claim (api/client-sdk.mdx:187-188passes singular'object';kernel/contracts/metadata-service.mdx:85is a baretype: stringsignature). Every spelling statement in the corpus —api/metadata-api.mdx:26-31andprotocol/kernel/http-protocol.mdx:877-879— is scoped under aGET /meta/{type}heading, i.e. the listing verb, which already folded; so nothing was made false, and nothing was quietly moved toward truth either. Follow-ups filed by the PM as #9192 (the page list was wrong in both directions) and #9193 (the refusal envelope is undocumented for all twelve verbs).Changed ranges
Declared from
git diff --unified=0 origin/main, not from symbol addresses:packages/metadata-protocol/src/protocol.ts—6376-6420,13443-13476,17883-17912PR #9173 (#9111) is open on the same file at
11398-11470/11477-11496/11650-11666— no overlap.Verification — all at
e96538ab7(current head, base671d4736f, 0 commits behind)Re-verified after the second
origin/mainmerge. No commit in that merge touchespackages/metadata-protocol/**— butpackages/spec/**moved (#9186,ObjectStackDefinitionSchemagoes strict) and spec is upstream of this package, so the dependency closure was rebuilt and the suite re-run rather than carried over:The downstream consumer sweep (
--filter '...@objectstack/metadata-protocol'— dependents, not dependencies) was run at the previous headb8731f7a3and is unchanged by this refresh, since nothing in the merge touches this package or its consumers' use of it: objectql 213 files / 3755 tests, rest 122 / 2011, runtime 165 / 2463 — all passed.Gate union re-derived and re-run at
e96538ab7, all PASS. The derivation itself was upgraded by #9188 in this very merge (it now states outright that 94 of 103 families sit in workflows with nopull_requestpath filter, i.e. CI schedules them on every PR):The last six are not named by the derivation and were read off CI's own job list (#9171 discipline).
check:stack-collection-mapsis new to this run and was added because its script moved in this merge and it governsPLURAL_TO_SINGULARitself — the map this change moves off.Type-check ratchet re-measured at the new head, not carried over:
tsc --noEmitoverpackages/metadata-protocolreports 63 errors, still exactly the ledger's recorded number, with zero from the new test file.Generated by Claude Code