Skip to content

fix(metadata): route the three read-side /meta verbs through the canonical type boundary (#9157) - #9191

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-9157-read-verb-canonical-fold
Aug 17, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-9157-read-verb-canonical-fold

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9157

Step ① of the maintainer ruling in #9180 (2026-08-16): the /meta type segment is singular, always, no exception.

auditMetaItem, historyMetaItem and findReferencesToMeta each opened by deriving their type key from PLURAL_TO_SINGULAR — the MANIFEST-COLLECTION map #7894 moved this boundary off — instead of calling canonicalizeMetaRequestType, which the nine sibling /meta verbs already call. That one call carries both the URL spelling map and metaUrlSpellingRefusal, and the refusal is the half these three could never reach: it lives inside the function they skipped.

⛔ Steps ② (the /meta/objects/:name/state/:field route rename) and ③ (docs + retiring PLURAL_TO_SINGULAR as a fold) are not in this PR. #9180 requires the three steps to stay independently revertible. #9174 remains open and is not addressed here.


⚠️ The card's premise, corrected by measurement

The filing card asserted that PUT /meta/views/all_leads is "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 on origin/main before writing any code:

metaUrlSpellingRefusal('views')        -> null          # recognised plural: FOLDS, never refused
metaUrlSpellingRefusal('translations') -> null          # ditto
metaUrlSpellingRefusal('viewes')       -> { declared: 'view', hint: 'views' }

views is in META_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:

caller's :type before after
viewes — unrecognised spelling of a declared type 200, empty body 400 INVALID_REQUEST, naming view and views
translations / fields / seeds / external_catalogs — recognised plurals of the four manifest-ABSENT types 200, empty body 200 with the real rows
views — recognised plural, manifest-PRESENT unchanged unchanged
fieldz — reaches for no declared type unchanged unchanged (the refusal stays narrow, so a plugin kind can never trip it)

Both 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/:type path segment arrives as:

  • 9 folded: getMetaItems, getMetaItem, getMetaItemLayered, getMetaItemCached, saveMetaItem, publishMetaItem, rollbackMetaItem, diffMetaItem, deleteMetaItem
  • 3 unfolded: auditMetaItem, historyMetaItem, findReferencesToMeta

The 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?:) and migrateStoredMetadata (types?: string[]) take the type as a query-string filter, not a path segment — a different contract, and migrateStoredMetadata documents accepting both spellings on purpose.

Two measured details that invert an intuition

  • On historyMetaItem the plural was a door AROUND a gate, not merely a wrong key. field declares neither allowOrgOverride nor allowRuntimeCreate, so the canonical spelling is refused by the overlay gate and never touches the store — while fields took isRuntimeCreateAllowed's no-static-registry-entry arm (the plugin path, permissive by construction) and issued a real sys_metadata_history read 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.
  • On findReferencesToMeta the refusal is the whole visible change. Every REFERENCE_PATHS key (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 as findReferencesToMeta answers {references: []} for every target type absent from REFERENCE_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.type and re-running the pin file:

Tests  9 failed | 9 passed (18)

× every `/meta` request-boundary verb with a required `type` calls the fold — all twelve
    → expected [ 'auditMetaItem', …(2) ] to deeply equal []
× auditMetaItem > THE PIN: a manifest-ABSENT plural reads the REAL audit trail
    → expected [] to have a length of 1 but got +0
× historyMetaItem > THE OTHER PIN: the plural stops being a door AROUND the overlay gate
    → expected [ 'fields' ] to deeply equal []
… 6 more, all refusal-envelope assertions

Every predicted-green control stayed green in both directions: the anti-vacuity arm, theRecognisedPluralsAreNotRefused, all four manifest-PRESENT / plugin-shaped controls, and the honest findReferencesToMeta scope pin. The fix was committed before the ablation, so restoring it was git checkout <branch> -- <path> and git status came 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, /audit and /references appear in exactly two pages, neither of which makes a spelling claim (api/client-sdk.mdx:187-188 passes singular 'object'; kernel/contracts/metadata-service.mdx:85 is a bare type: string signature). Every spelling statement in the corpus — api/metadata-api.mdx:26-31 and protocol/kernel/http-protocol.mdx:877-879 — is scoped under a GET /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-17912

PR #9173 (#9111) is open on the same file at 11398-11470 / 11477-11496 / 11650-11666 — no overlap.

Verification — all at e96538ab7 (current head, base 671d4736f, 0 commits behind)

Re-verified after the second origin/main merge. No commit in that merge touches packages/metadata-protocol/** — but packages/spec/** moved (#9186, ObjectStackDefinitionSchema goes strict) and spec is upstream of this package, so the dependency closure was rebuilt and the suite re-run rather than carried over:

@objectstack/metadata-protocol   116 files / 1606 tests passed   (at e96538ab7)

The downstream consumer sweep (--filter '...@objectstack/metadata-protocol' — dependents, not dependencies) was run at the previous head b8731f7a3 and 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 no pull_request path filter, i.e. CI schedules them on every PR):

check:changeset-gate-self-tests   check:cross-package-test-inputs   check:durability-log-level
check:filter-alias-parity         check:objectui-changeset          check:nul-bytes
check:engine-double-contract      check:where-matcher               check:query-options-erasure
check:type-check-coverage         check-adr-0087-registration       check-changeset-no-major
check-cross-package-test-inputs   check-empty-changeset
check:meta-type-normalized        check:error-code-casing           check:error-status-conformance
check:dispatcher-error-vocabulary check:type-source-resolution      check:stack-collection-maps

The last six are not named by the derivation and were read off CI's own job list (#9171 discipline). check:stack-collection-maps is new to this run and was added because its script moved in this merge and it governs PLURAL_TO_SINGULAR itself — the map this change moves off.

Type-check ratchet re-measured at the new head, not carried over: tsc --noEmit over packages/metadata-protocol reports 63 errors, still exactly the ledger's recorded number, with zero from the new test file.


Generated by Claude Code

claude added 3 commits August 16, 2026 23:48
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)

⛔ 1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

os-project-manager pushed a commit that referenced this pull request Aug 17, 2026
…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

Copy link
Copy Markdown
Contributor Author

契约复审(needs:contract-review 清标签)— 通过

复审席:skills 席 session_017TNzEetykdh7ceZGwuAPLq(契约复审档位,非派发席,过渡期代行)。范围:契约行为增量全文(protocol.ts 三处折叠接入 + 测试断言清单 + changeset 的 wire 变更表)。

结论:

  1. 接受→拒绝翻转(已声明类型的误拼 viewes:200 空体 → 400 INVALID_REQUEST 且指名 view/views)——有维护者裁决背书(2026-08-16「/meta type segment 单数,恒定」step ①),含裁决要求的引导句条件,信封整体断言(code+status+指名)。
  2. 拒绝面窄口保持:plugin 形拼法(fieldz)不可误拒有钉;已识别复数不拒有反捆绑钉(step ③ 独立可回滚)。
  3. manifest 缺席类修正(translations/fields 折叠后读真行)非公开面加宽——同一已声明契约下的错键修正;history 侧「复数绕门」路径同步关闭(断言读 KEY 而非只读 body,正确)。
  4. 12 动词 population 由代码派生并锚定计数,三读动词与写动词信封逐字节同一。立卡时的失实主张(views 在写动词被拒)已实测证伪并钉住。
  5. 未发现缺陷。 档位违规为程序性(低档派发已由派发席自报),实质变更是裁决的执行——本复审即条款②闸门放行记录,清标签后可按正常落地流程入队。

Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 3b0b61c Aug 17, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9157-read-verb-canonical-fold branch August 17, 2026 08:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three read-side /meta verbs (history, audit, references) never reach canonicalizeMetaRequestType — they still derive their key from the MANIFEST map

2 participants