Skip to content

feat(spec, metadata-protocol): each _drafts row carries the draft body's own label, or null - #22323

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22200-drafts-header-label
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22200-drafts-header-label

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22200
Clause-②: yes (widening: a new field on a published response schema)

What changes

Each row of the pending-drafts list (GET /api/v1/meta/_drafts, the runtime's GET /metadata/_drafts, client.meta.listDrafts()) now carries label: the draft body's own top-level label, as authored, or null when the body declares none. It is never the machine name standing in for a missing label.

  • Contract (packages/spec/src/api/protocol.zod.ts): ListDraftsResponseSchema rows gain label: I18nLabelSchema.nullable(), required on the wire, with a describe that says it is the body's own label and never a name fallback.
  • Producer (packages/metadata-protocol/src/sys-metadata-repository.ts): SysMetadataRepository.listDrafts reads the label off the sys_metadata row it already fetches (draftBodyLabel), so there is no second query. The declared return gains label: I18nLabel | null.
  • Pass-through (packages/metadata-protocol/src/protocol.ts): ObjectStackProtocolImplementation.listDrafts declares the same member and passes the repository's rows through unchanged. The docblock on the publish batch's closure read, which listed the six header members, now lists seven.

The maintainer's direction on objectstack-ai/objectui#11862, quoted by the card: 「所有地方以标签为主,机器名只作为次要信息」. The producer carries the label; consumers do not each fetch one. Triage's grade, quoted: "Each _drafts row carries the draft body's own label, or null"; "Add a row whose draft body declares no label and reads null, never a fallback."

Measurements behind the shape (each PM hypothesis, measured on this branch's base 238222d8c)

H1: holds, with one refinement. listDrafts calls engine.find('sys_metadata', { where, context }) with no field projection, so the whole row is in hand. The body is NOT a column of its own: sys_metadata has no label column (packages/metadata-core/src/objects/sys-metadata.object.ts); the label lives inside the metadata textarea column, stored as JSON text (an already-parsed object on a JSON-column dialect). So the producer parses row.metadata once per draft row, through the same storedRowBody reader rowToItem uses. No second query.

Label spelling per metadata type, read from getMetadataTypeSchema(type) over every DEFAULT_METADATA_TYPE_REGISTRY entry (built packages/spec/dist, z.toJSONSchema, input side):

top-level label shape types
plain string object, field, hook, picklist, mapping, flow, job, datasource, translation, email_template, doc, book, permission, position, capability, agent, tool, skill
I18nLabel (string or inline locale map) view (container and view items), page, dashboard, app, action, report, dataset
no label key seed, api (rows read null)
no schema external_catalog (any stored label is judged by I18nLabelSchema)

No registered type spells its display label title or nests it. Off-registry: connector, sharing_rule and webhook spell it label (string); analytics_cube spells it title and its schema refuses label with guidance to title, so a cube draft reads null (see Acceptance notes).

No ADR-0087 conversion rewrites a top-level label (packages/spec/src/conversions/registry.ts: the only label entries are the nested datasource.external.label removal and fixtures), so the stored spelling is already the canonical one and reading it raw needs no conversion replay.

H2: holds; no third file moves. Both faces serve the protocol's return whole: packages/rest/src/rest-server.ts GET ${metaPath}/_drafts ends in res.json(result), and packages/runtime/src/domains/meta.ts _drafts ends in deps.success(data). packages/client/src/index.ts meta.listDrafts types its answer as the spec's ListDraftsResponse. packages/rest and packages/runtime are untouched.

H3: holds. Seven types declare label as I18nLabelSchema (string or inline locale map such as { en, 'zh-CN' }). What the producer does with each shape:

  • plain string: carried verbatim.
  • inline locale map: carried verbatim, not resolved. The route takes no locale, so resolving here would be the producer choosing a language for the reader; the reader resolves it the way it resolves every other I18nLabel (resolveI18nLabel in @objectstack/spec/ui, or objectui's pickLocalized).
  • absent / null: null.
  • any other stored value (a number, an array, the retired key-reference form): null. I18nLabelSchema.safeParse is the judge, so the declared field never carries a shape its own type rules out. Draft saves are schema-validated (resolveOverlaySchema in saveMetaItem), so this is reachable only for rows stored before a type's schema was enforced on save, or for a type with no registered schema.
  • stored bytes that do not parse: null, and the draft stays listed. This is lockHead's answer for the same bytes, in the same file, for the same reason: a header listing must not become a parse failure. The draft stays visible and discardable, while every read of its body still fails loudly.

Landing and surface

The landing matches the claim's surface, plus one test file outside it, named here with its reason:

  • packages/objectql/src/sys-metadata-repository-list-drafts.test.ts is a test of SysMetadataRepository.listDrafts that lives in packages/objectql. Its finding: /meta/_drafts serves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599 disclosure pin asserted exactly six header keys, so it goes red on any seventh. It now names seven keys. Its fixture now carries the body in the metadata column the repository actually reads, beside the two older spellings, so the whole-payload sweep covers the real column. It asserts the label arrives while every field-level secret stays off the wire. Its docblock said the routes had "no capability gate"; both routes now gate on mayReadPendingDrafts, so that sentence is corrected.
  • Declared cross-lane files: packages/metadata-protocol (domain:engine), declared by the seat on [PM seat] domain:engine — ⏳ vacant #6367.

Tests

Readings at head 317b208be. Every run went through scripts/pm/os-verify-lock.sh on a shared box. The full report comment on the card carries the rest.

  • @objectstack/spec tests:
    • project local, all 8 shards: 625 files, 18713 passed, 1 todo, 0 failed;
    • project repo, shard 1/2: 27 files, 399 passed. The report states the reading for shard 2/2, or NOT MEASURED with the reason;
    • typecheck (tsc, scripts and the test layer) exits 0.
  • @objectstack/metadata-protocol tests: the whole suite has 221 files passed and 3 skipped, with 28287 tests passed and 19 skipped. typecheck exits 0.
  • @objectstack/objectql: sys-metadata-repository-list-drafts.test.ts has 7 passed. typecheck (with the test layer) exits 0.
  • @objectstack/rest: the 18 test files that name _drafts have 906 tests, all passed. packages/rest itself is unchanged.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands was re-derived after the change and gave 113 families. All 113 were run, every one exited 0, and --ran reports "113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED". pnpm --filter @objectstack/spec check:generated --fix regenerated only content/docs/references/api/protocol.mdx, and check:docs re-checked green.

What the new pins cover:

  • @objectstack/spec: the schema pin "ListDraftsResponseSchema declares the pending-drafts body" now carries a string label, an inline-locale-map label and a no-label row reading null. It also pins that a row omitting label is refused at drafts.0.label.
  • @objectstack/metadata-protocol (sys-metadata-repository-14938-list-drafts-updated-at.test.ts, which shares its pinned engine double): the declaration-keyed CONFORMS table gains label, so every existing case also checks it. New #22200 cases cover:
    • a label written through the real put(..., { state: 'draft' }) and read back;
    • a body with no label, which reads null, not the name;
    • a locale map carried verbatim;
    • a JSON-column body;
    • three off-spec labels, each reading null;
    • torn stored bytes, which read null while the draft stays listed;
    • ObjectStackProtocolImplementation.listDrafts passing the label through, its response parsed by ListDraftsResponseSchema with every member preserved, and exactly seven header keys with no body residue.
  • Ablation, run once and not committed, through scripts/ablation-replace.mjs: the producer line label: draftBodyLabel(row), was replaced by the forbidden fallback label: row.name ?? null,. All 10 #22200 cases went red and the 8 #14938 cases stayed green. The file was restored to its HEAD blob and git diff HEAD was empty.
  • Reverse type verification: a ListDraftsResponse literal without label, planted in a metadata-protocol test file, fails tsc with TS2741: Property 'label' is missing. So the consumer reads the rebuilt spec .d.ts. The file was restored to its HEAD blob.

Acceptance notes (observations; not filed)

  • analytics_cube drafts read label: null. CubeSchema spells its display name title and refuses label with guidance to title. This PR reads only label, the field the ruling names. A per-type display-key mapping would be a second vocabulary for the producer to keep in step, so it is not added here. Reach is not measured: no cube draft producer was found in this repository. Carrier: none.
  • An empty-string label ('') is carried as ''. It is the body's own value and passes I18nLabelSchema. Whether a reader treats it as absent is the reader's choice.
  • objectui's half stays on studio: machine names and raw ids shown where a label exists — Explain access principal, permission-set list, nav editor items, Changes panel objectui#11862. MetadataDraftHeader in @object-ui/data-objectstack follows after the pin bump that carries this field. No objectui change is made here.

Generated by Claude Code

claude added 3 commits October 8, 2026 13:26
…y's own label, or null

ListDraftsResponseSchema gains a required, nullable `label` (I18nLabel):
the draft body's own top-level label, as authored. SysMetadataRepository
reads it off the row listDrafts already holds; the protocol passes it
through. A body that declares none reads null, never the machine name.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
… authored, null when absent, the only body member on the header

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 6 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via listDrafts (symbol, a method of class ObjectStackProtocolImplementation; a method of class SysMetadataRepository))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), SysMetadataRepository (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/meta/drafts (route, the route ledger binds it to client method meta.listDrafts))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 440bed63e731117bc194166fea3eec3d923ad2c6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 347b3754f4f14d93827bcb46dd6335a63f890887 — the merge of head 98a5aef8dcd71f0cd0c5ac6a866b1c133df06866 into base 440bed63e731117bc194166fea3eec3d923ad2c6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 347b3754f4f14d93827bcb46dd6335a63f890887 && git checkout 347b3754f4f14d93827bcb46dd6335a63f890887
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 440bed63e731117bc194166fea3eec3d923ad2c6 98a5aef8dcd71f0cd0c5ac6a866b1c133df06866 && git checkout -B drift-repro 440bed63e731117bc194166fea3eec3d923ad2c6 && git merge --no-ff 98a5aef8dcd71f0cd0c5ac6a866b1c133df06866

node scripts/docs-audit/affected-docs.mjs --json 440bed63e731117bc194166fea3eec3d923ad2c6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 440bed63e731117bc194166fea3eec3d923ad2c6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI on 317b208be1, two red Test Core shards · domain:spec seat 2 (#18549) · session session_01DhTqaEHqPVSVnAkjG3jywn · 2026-10-08T16:51Z. The dev is still in flight on #22200.

  • Test Core (6/6): not this PR's. No test failed. The shard-timing drift step reads 2512.7s measured vs 1609.1s predicted (1.56x, red past 1.5x). The overshoots are @objectstack/runtime 1.68x, plugin-security 1.57x and driver-sql 1.56x, packages this diff does not touch; check-test-completeness reports every scheduled test accounted for. This is a slow runner, not the dataset or this change.
  • Test Core (2/6): one timeout, being measured. packages/spec/src/integration/connector-author-shape.test.ts:194 ("compiles every full connector example verbatim") timed out at 60000ms; the other 18721 spec tests passed in a run whose imports took 402s. The dev times that test on main and on this head under the shared verify lock before reporting. If this PR slows the compile probe, the fix lands here; ⛔ the timeout is not raised and the test is not skipped.
  • Re-run: this seat has no re-run op (the relay carries none). The next push to this branch, a fix or a merge of main, re-runs both shards.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 81cd9291bc383e6fc64e39e130d1dd0dde543015
Local-runs: none

Inputs read: card #22200 (body and all 5 comments: triage 6054566832, claim 6060635187, dev reports 6065161975 and 6065768152, the seat's accept 6065790260), PR #22323 (body, 8-file list, the net diff 28bff18d0..81cd9291b, +311/−17), the 35 check-runs on the head, and on origin/main AGENTS.md (Prime Directives #2, #12, #14; Post-Task Checklist step 3; "Touched packages/spec"), ADR-0087, packages/spec/src/ui/i18n.zod.ts, the two _drafts routes, the SDK client, the route ledgers, sys-metadata-repository.ts (storedRowBody, rowToItem, lockHead), the conversions registry, the three docs the drift check named, and the objectui sibling's MetadataDraftHeader. Nothing was built, run or re-run; the one gh api …/jobs/…/logs call was a read that returned no bytes (blob host not reachable) and was answered instead from the job's step conclusions and the check-run annotations.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. ListDraftsResponseSchema rows gain label: I18nLabelSchema.nullable(), required (packages/spec/src/api/protocol.zod.ts:1801). Accept set: a row without the key is now refused at drafts.0.label; a row whose label is a string, an inline locale map of strings, or null is accepted. Right. The ruling's "reads null, never a fallback" needs the key present, so required-nullable is the honest shape and .optional() would have re-opened the ambiguity. Who parses it today: nobody on the wire. REST GET /api/v1/meta/_drafts ends in res.json(result) (rest-server.ts:5930), the runtime _drafts door in deps.success(data) (domains/meta.ts:1855), the SDK meta.listDrafts() in unwrapResponse (a cast, client/src/index.ts:2050), and both route ledgers name the schema as describe-only transcription with conformance pinned in protocol.test.ts. So the schema's narrowing lands only on the one producer this repo owns (judgment 3), and every reader gains a member. The type-level consequence for a downstream that builds a ListDraftsResponse literal (TS2741 without label) is the one the dev's reverse check measured and the changeset's "required on the wire" states.
  2. protocol.mdx regenerated (summary row and nested-shape row both carry label). Right: gen:docs output, not hand-written; api-surface/ records export existence only and no export was added, so no api-surface move was owed; the JSON-schema/authorable-surface manifests cover metadata types, not API response schemas. The TypeScript Type Check job, which holds every spec artifact gate, is green on the head.
  3. Producer draftBodyLabel(row) (sys-metadata-repository.ts:223-232, used at :1383). Reads storedRowBody(row) — the same reader rowToItem uses, so the same metadata column — then I18nLabelSchema.safeParse(body?.label); null on refusal and on a parse throw. Right on each branch:
    • never the machine name — the function never references row.name; the dev's ablation (label: row.name ?? null) turned all 10 new cases red;
    • no second query — the engine.find call is unchanged and unprojected; the cost added is one JSON.parse per draft row, at console scale;
    • a locale map carried verbatim — the route takes no locale, and resolveI18nLabel (@objectstack/spec/ui) / pickLocalized (objectui) exist on both sides;
    • off-spec stored label → null is not a PD Add comprehensive test suite for Zod schema validation #12 consumer fallback: it is the declared "absent" answer for a shape the field's own type cannot carry, at the read door this repo owns; draft saves are schema-judged (resolveOverlaySchema, protocol.ts:793), so the branch is reachable only for pre-enforcement rows or unregistered types; and no ADR-0087 conversion rewrites a top-level label (the registry's only label entries are the nested datasource.external.label removal and fixtures), so the raw spelling the header reads is the one rowToItem serves too;
    • torn bytes → null with the draft still listed mirrors lockHead (sys-metadata-repository.ts:2188-2197 on the head) line for line: a header listing must not become a parse failure, and every body read still fails loudly.
  4. Protocol pass-through (protocol.ts:22705-22734): declared return gains label: I18nLabel | null; getOverlayRepo() returns SysMetadataRepository, so { drafts } carries the widened rows with no runtime line moved; I18nLabel was already imported at :154. The publish batch's closure docblock now lists seven header members. Right.
  5. Boundaries of the new import edges. api/protocol.zod.ts already pulled ../ui/view.zod, so ../ui/i18n.zod adds no entry-closure edge; sys-metadata-repository.ts imports a value from @objectstack/spec/ui, which protocol.ts in the same package already does, on a declared dependency. Right; Lint & Repo Gates and the four Type Check jobs are green.
  6. Disclosure boundary (finding: /meta/_drafts serves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599) held. Exactly one body member leaves. Both key-count pins now say seven, and the objectql sweep still asserts fields, option values and the formula stay off the wire — now against the real metadata column as well as the two older spellings. These are the only two tests on main that pin the header's key set (searched: no other updatedBy key list exists in packages/**/*.test.ts). Right, and the out-of-surface objectql test file is the consequence the seat accepted.
  7. Pinned sibling. objectui's MetadataDraftHeader is a plain interface and listDrafts casts res.json(); nothing parses strictly, so the extra key is harmless at the pin, Console Pin Gate skipped on path, and the objectui widening stays on studio: machine names and raw ids shown where a label exists — Explain access principal, permission-set list, nav editor items, Changes panel objectui#11862. Right.
  8. Hand-written docs. client-sdk.mdx, metadata-lifecycle.mdx and ui/apps.mdx name the route or the gate only; none enumerates the row's fields, so none is falsified. Right.
  9. Two edge values carried as authored — '' (named in the PR) and the empty map {} (not named; same class, z.record admits it). Both are the body's own value under I18nLabelSchema; the reader resolves. Observation, no change owed.

② Semver level

Changeset .changeset/22200-drafts-header-label.md: @objectstack/spec minor, @objectstack/metadata-protocol minor, body carrying Clause-②: yes (widening); the PR body carries Clause-②: yes (widening: a new field on a published response schema); the claim says the same. Matches what the diff publishes. For every reader of the public surface the change is additive; the accept-set narrowing (the key is required) falls only on the producer this repo owns, which is the internal-contract case PD #12 describes. AGENTS.md step 3: yes takes at least minor — met; no (narrowing) arm, no removal, no rename, so no migration text or ADR-0087 disposition is owed. skip-changeset does not apply and is not applied. No changeset is owed to @objectstack/objectql (test-only), rest, runtime or client (untouched; the client's return type follows spec). Check Changeset is green on the head. The five changeset sentences (what a client reads, its shape, when null, where from, unchanged) each match the diff as read in ①.

Clause-②: yes (widening) — agreed.

③ Boundary flags

  • open_questions: [] in both dev reports. Nothing to answer.
  • Dev deviations (round 1, six): out-of-surface objectql pin — accepted, ① item 6; protocol.ts type-only — confirmed, ① item 4; sharded spec runs — superseded by the head's check-runs; no labels written — consistent with the dispatch; attribution trailer form — AGENTS.md's, not a contract matter; CI 2/6 timeout — Test Core (2/6) is green on this head, closing that flag. Round 2: none.
  • out_of_scope_findings (carrier none): analytics_cube drafts read label: null because CubeSchema spells its display name title — consistent with the ruling's own word (label), and a per-type display-key map would be a second vocabulary; reach unmeasured; acceptance note is the right carrier. Empty-string label carried — the reader's choice on objectui#11862. I add the empty-map sibling (① item 9) to the same note.
  • Check-runs on the head, 35 read on 2026-10-08 after the 6/6 job completed at 17:52:55Z: 31 success, 2 skipped (Console Pin Gate, Packed-tarball smoke, both path/opt-in), 2 failure: Test Core (6/6) and its aggregate Test Core. On 6/6 the steps "Run this shard's tests" and "Test completeness guard" are success; the only failed step is "Check this shard's timing drift" (partition-test-shards.mjs --check-drift, reds past 1.5x measured/predicted), and the aggregate reds because that shard published no attestation. The log itself was not reachable (blob host), so the per-package ratios on this head are unread. The seat's reading on finding(ci): the shard-timings dataset rests on ONE scheduled run, and records @objectstack/spec at 1134.86 s against 1573–1651 s executed — #16468's 25%-headroom ceilings built on it would red every PR that runs spec #22014 (6065689519, open, p2, domain:devx) measured the same shard at 1.52–1.56x on three unrelated PRs the same day, one comments-only, with the overshoots in runtime, plugin-security and driver-sql — none in this diff — and this PR's packages (spec at 1134.86s is its own shard's heaviest) are not on that shard. Escalated, not a contract defect: the gate verdict on the head is red and the landing rule needs every check green; the seat holds no re-run op, so the path is finding(ci): the shard-timings dataset rests on ONE scheduled run, and records @objectstack/spec at 1134.86 s against 1573–1651 s executed — #16468's 25%-headroom ceilings built on it would red every PR that runs spec #22014's dataset refresh (the maintainer workflow_dispatch its triage names) or a re-run of 6/6 after it. The contract review does not substitute for that green.
  • No governed surface in the file list (none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, NORTH-STAR); Governed Surface Queue Guard green.

Implemented-by: claude/issue-22200-drafts-header-label
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…or before the checks that judge its body, on every kernel topology (objectstack-ai#22338)

Fixes objectstack-ai#22220
Clause-②: no

## What changes

`saveMetaItem`'s package door (`refusePackagedBaseOverride`) is now
asked on every kernel topology, at the position it already had on an
environment kernel: after the code-only and organization-scope refusals,
before the item lock and before every check that reads the body or the
store. It used to sit behind `environmentId !== undefined`. A
host-config kernel (the CLI's assembler, the showcase's boot shape,
`OS_MODE=off`) met the same predicate only at
`SysMetadataRepository.assertAllowed`, the first statement of
`repo.put`, which is the method's last act. So on that kernel every
refusal in between answered first.

- `packages/metadata-protocol/src/protocol.ts`: the `environmentId`
wrapper around the door is removed. The `_lock` gate's guard
`packagedBaseRefusal(...) === null` (its hand-written deferral to the
door on host-config) always answered "no refusal" once the door runs
first on every kernel, so it is removed. The invariant comment on that
gate is extended, and the door's call site records why no acceptance set
moves. Two TSDoc paragraphs that said the door is environment-only are
corrected.
- New pins:
`packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts`
(one table over both kernel topologies, `code` + `status` per row).
- Three downstream test files pinned the old host-config ordering and
are updated (below).
- `scripts/engine-double-contract.pinned.json`: the new pin file's
`findOne` double, recorded by `check-engine-double-contract.mjs
--write`.
- `.changeset/22220-package-door-before-gates.md`:
`@objectstack/metadata-protocol` patch.

## Door table, live (base `4e4111ca05` vs head `9e763ec0bc`, both built
and booted before the `main` merge)

Seeded admin, default composition, `OS_METADATA_WRITABLE` unset. Bodies:
**served** = the `GET` item; **gate-refused** = served plus one
autonumber field whose format names a missing field
(`autonumber-references-unknown-field`); **spec-refused** = served plus
an undeclared top-level key (`unrecognized_keys`). Every cell is `status
code`.

`examples/app-crm`, `PUT /api/v1/meta/object/crm_account` (packaged
under `com.example.crm`; `object` is `allowOrgOverride: false`).
Environment kernel = `pnpm dev:crm -- --fresh` (`env_local`).
Host-config kernel = the same stack under `OS_MODE=off` (the lightweight
assembler, `environmentId` undefined; confirmed by the repository's
sentence on the base row).

| body, mode | env kernel, base | env kernel, head | host-config, base |
host-config, head |
|:--|:--|:--|:--|:--|
| served, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
| served, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
| gate-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
**422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |
| gate-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
| spec-refused, publish | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
**422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |
| spec-refused, draft | 403 NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE |
**422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |
| `permission/crm_sales_user`, spec-refused, publish | not measured |
403 NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |
| `position/sales_rep`, spec-refused, publish | not measured | 403
NOT_OVERRIDABLE | **422 INVALID_METADATA** | 403 NOT_OVERRIDABLE |
| control: env-local `zz_local_obj`, gate-refused, publish | 422
INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422
INVALID_METADATA |
| control: env-local `zz_local_obj`, spec-refused, publish | 422
INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422
INVALID_METADATA |

At head the host-config 403 carries the environment kernel's sentence:
the two `crm_account` gate-refused publish bodies compare byte-equal. At
base the host-config 403s carried the repository's sentence (`'object'
is not allowOrgOverride in the registry ...`), and a packaged
`permission`'s plain publish carried plugin-security's lock sentence.
The environment kernel's code path is unchanged by this diff, which is
why its two unmeasured base cells are left unmeasured rather than
inferred.

The card's own composition, `examples/app-showcase` (`pnpm dev --
--fresh`, host-config), `PUT /api/v1/meta/object/showcase_task`: base
answered 422 for gate-refused publish and for spec-refused publish and
draft, 403 for the rest; head answers 403 `NOT_OVERRIDABLE` for all six
rows, and the env-local controls answer 422 `INVALID_METADATA`.

## Door table, unit pins (both kernels; base = `protocol.ts` at the
merge base, head = this branch)

Measured through the real `saveMetaItem` over an engine double (the new
pin file's harness). "gate reached" = `assertRuntimeAuthoringRules` was
called.

| request | env base | env head | host-config base | host-config head |
|:--|:--|:--|:--|:--|
| object, served body, publish | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403
NOT_OVERRIDABLE |
| object, gate-refused, publish | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 422 INVALID_METADATA, gate reached | 403
NOT_OVERRIDABLE |
| object, gate-refused, draft | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 403 NOT_OVERRIDABLE, gate reached | 403
NOT_OVERRIDABLE |
| object, spec-refused, publish | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |
| object, spec-refused, draft | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |
| object, `?package=` naming its read-only package, gate-refused,
publish | 403 ITEM_LOCKED | 403 ITEM_LOCKED | 422 INVALID_METADATA, gate
reached | 403 ITEM_LOCKED |
| object, fields dropped (destructive), publish | 403 NOT_OVERRIDABLE |
403 NOT_OVERRIDABLE | 409 DESTRUCTIVE_CHANGE | 403 NOT_OVERRIDABLE |
| position, spec-refused, publish | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |
| permission, spec-refused, publish | 403 NOT_OVERRIDABLE | 403
NOT_OVERRIDABLE | 422 INVALID_METADATA | 403 NOT_OVERRIDABLE |
| control: env-local object, gate-refused, publish | 422, gate reached |
422, gate reached | 422, gate reached | 422, gate reached |
| control: env-local object, spec-refused, publish | 422
INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422
INVALID_METADATA |
| control: packaged view (`allowOrgOverride: true`), spec-refused | 422
INVALID_METADATA | 422 INVALID_METADATA | 422 INVALID_METADATA | 422
INVALID_METADATA |

Registry-wide (a packaged `pkg_TYPE` with a spec-refused body, publish,
every type in `DEFAULT_METADATA_TYPE_REGISTRY`): at base the host-config
kernel answered differently from the environment kernel for 15 types
(`object` 409 `DESTRUCTIVE_CHANGE`; `hook`, `seed`, `mapping`, `page`,
`app`, `action`, `dataset`, `datasource`, `doc`, `book`, `permission`,
`position`, `tool`, `skill` 422 `INVALID_METADATA`). At head both
kernels give the same envelope for every type, and every type the door
governs (`allowOrgOverride: false`, `allowRuntimeCreate: true`) answers
403 `NOT_OVERRIDABLE`. The other 13 types answered alike on both kernels
at base and at head.

## The window the door now precedes (M2)

Refusals `saveMetaItem` could answer on a host-config kernel between the
door's position and `repo.put`, for a packaged `allowOrgOverride: false`
save:

- the ADR-0010 `_lock` gate, 403 `ITEM_LOCKED` (already deferred to the
door by hand; that guard is removed);
- the ADR-0029 D9.9 package mismatch, 422
`OBJECT_OVERLAY_PACKAGE_MISMATCH`;
- the destructive diff, 409 `DESTRUCTIVE_CHANGE` (measured above);
- the layered-envelope refusal, 422 `INVALID_METADATA`; the save-name
refusal, 400 `VALIDATION_ERROR`;
- the flow conversion conflict, 409 `FLOW_CONVERSION_CONFLICT`;
- the spec-conformance parse, 422 `INVALID_METADATA`, draft and publish
(measured);
- the stored-hook body refusal, 400 `VALIDATION_ERROR`;
- the runtime authoring gate, 422 `INVALID_METADATA`, publish only
(measured);
- the domain plugins' authoring gates: plugin-security's permission-set
lock (403 `NOT_OVERRIDABLE`, its own error class and sentence; measured
live) and its object posture gate R1 (403, wire `code`
`PERMISSION_DENIED`, `declaredCode` `owd_widening_forbidden`).

The view-container collision refusal also sits there but judges only
`view`, which allows overlays, so the door never precedes it. ADR-0070
D1 (`WRITABLE_PACKAGE_REQUIRED`) judges only `runtime-only` writes,
which the door never refuses.

## Mechanism assumptions, as measured

- **M1** reproduced at base on both compositions above. The
authoring-gate body was built from what `main` refuses
(`autonumber-references-unknown-field`); no pass-4 lift was needed.
- **M2** the spec parse also answers 422 ahead of the door on
host-config, in draft and publish mode; the full window is listed above.
- **M3** the predicate is `refusePackagedBaseOverride`'s own, already
topology-independent (`packagedBaseRefusal` asks it on every topology
for the `/automation` doors). It and `assertAllowed` read the same
registry-derived `allowOrgOverride` set, the same `OS_METADATA_WRITABLE`
hatch and the same `isWritablePackage` (`package-writability.ts`), and
both throw the same `readOnlyBaseOverrideError` for a named read-only
base. They differ only in the fallback sentence for a type with no
ADR-0126 regime row.
- **M4** read `0b997ea447`. The door's input is `isArtifactBacked`, the
same input the repository's intent uses, so stack-declared positions are
refused by the door on both kernels (measured live for
`position/sales_rep`). The `security`-domain types are `permission`,
`position` and `capability`; `capability` is code-only and answers the
code-only refusal ahead of the door, unchanged.
- **M5** held: a draft is still not judged by the authoring gate;
env-local bodies still answer 422 on both kernels; a packaged `view` and
a packaged object with the hatch open are still judged by the gates.

## What does not move (Clause-② measurement, on this head)

- **Accept sets.** The door refuses exactly when `artifactBacked &&
!isOverlayAllowed(type)`. `repo.put` runs `assertAllowed` with intent
`override-artifact` whenever `artifactBacked`, and that refuses on the
same predicate, on every topology. `saveMetaItem` has no success return
before `repo.put` (the one `return` in that window is inside a closure).
So a request the door refuses was refused before, and a request it
admits meets the same checks as before. The unit tables above show every
measured row refused at base and at head.
- **Built entry declarations.** `dist/index.d.ts` and `dist/index.d.cts`
of `@objectstack/metadata-protocol`, built from this head and from the
merge base's `protocol.ts` (`fe98cc63a4`): the only differences are the
two TSDoc paragraphs corrected above. No declaration line changes.
- **Wire.** For a packaged `allowOrgOverride: false` save on a
host-config kernel that one of the checks above refused, the answer is
now 403 `NOT_OVERRIDABLE` (or 403 `ITEM_LOCKED` for a named read-only
base) with the door's sentence, which is what an environment kernel
already answered.

## Downstream pins that encoded the old host-config order

Each relied on the host-config kernel skipping the door. Each now
reaches the check it tests the way an environment kernel always had to.

- `packages/objectql/src/protocol-destructive.test.ts`: saved a
destructive edit of a packaged object with no `environmentId` "to bypass
the overlay opt-in gate". It now opens `OS_METADATA_WRITABLE=object`,
the one route by which a packaged object's write reaches the destructive
diff. Expectations unchanged.
- `packages/rest/src/meta-object-owd-gate.test.ts`: the lint-before-R1
order case and the two R1 refusals drove a packaged-object overlay with
the hatch shut. They now open the hatch (the path R1's docblock names).
Expectations unchanged. Two comments that said the door was
environment-scoped are corrected.
-
`packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts`:
the hatch-closed case pinned the lock's error class on host-config. With
the hatch closed the door now answers first there, as on an environment
kernel, so the case asserts the same `NOT_OVERRIDABLE` / 403 envelope
and that the class is not the lock's. The hatch-open cases still pin the
lock's class.

## Verification

- Reverse verification: the new pin file run against the merge base's
`protocol.ts` (swapped on disk, blob-verified, restored by trap to the
HEAD blob): 23 failed / 26 passed of 49, every failure a host-config row
or a registry row; at head 49/49.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 222
files passed, 3 skipped; 28329 tests passed (at `9e763ec0bc`, before the
`main` merge, which touched only the seed loader in this package).
- At `81a42b1203`, after `git merge origin/main` and a rebuild: the new
pin file 49/49; `objectql` `protocol-destructive.test.ts` 7/7;
`plugin-security` `packaged-permission-set-lock-gate.test.ts` 7/7;
`rest` `meta-object-owd-gate.test.ts` 14/14; the five `qa/dogfood` files
that drive `saveMetaItem` 27/27. `typecheck` (with
`check:test-typecheck` where the package has it) green for
`metadata-protocol`, `objectql`, `plugin-security` and `rest`.
- Before the merge: `objectql` full `--project local` (383 files passed;
the 3 failures were `protocol-destructive.test.ts`, updated above),
`plugin-security` full (181 files passed; the 1 failure was the
lock-gate case updated above), the 35 `runtime` files and 24 `rest`
files that call `saveMetaItem` (all green except the 3
`meta-object-owd-gate` cases updated above).
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 78
commands on `81a42b1203`. 70 ran on `32d94c2d00` (the merge commit; the
one later commit only adds the ledger row); the 8 that row adds, the
changeset gates and the ratchet families re-ran on `81a42b1203`. 77
exited 0; `--ran` reconciles 78 derived, 77 run, 1 unrun.
`check:engine-double-contract` first exited 1 for the missing ledger row
and exits 0 with it recorded. `check:type-check-debt` (a repository-wide
re-measure) hit a 400 s local timeout: **NOT MEASURED**, left to CI.
- Lint, narrowed: ESLint's own config matches 5 of the 7 changed paths
(the changeset and the JSON ledger answer "no matching configuration");
`--format json` reports 5 files, 0 errors, 0 warnings. This config
enables no type-aware linting, so the diff cannot move a verdict on an
untouched file.

## Serial notes

- PR objectstack-ai#22319 edits `saveMetaItem`'s flow canonicalization and spec-parse
region; this diff stays out of those lines.
- PR objectstack-ai#22323 edits `protocol.ts` around the drafts listing and
`sys-metadata-repository.ts`; no overlap with this diff.

## Acceptance notes

-
`packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts`'s
`hostConfigDoor` docblock still says `saveMetaItem` skips its package
door on host-config. The test measures `repo.put` directly and stays
correct; only that sentence is now stale. Not edited here (outside the
claimed file surface).
- `packaged-base-regime.ts` and `sys-metadata-repository.ts` describe
the protocol door as environment-scoped (incomplete now, not false). Not
edited here, for the same reason.
- ADR-0005 §"Whitelist enforcement" still says single-kernel deployments
keep "any type writable". The repository's `assertAllowed` has refused
these writes on those kernels since before this change; this diff moves
no acceptance set, so it reverses no ADR decision. The ADR text is stale
relative to shipped behaviour, not to this change.
- `deleteMetaItem` keeps its own `environmentId`-scoped removal door;
this card is about the save door only.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…n in words instead of a tracker number (stage 30) (objectstack-ai#22414)

Part of objectstack-ai#20749
Clause-②: no

Stage 30 of this card: the class (e) remainder, the test strings shipped
under the `packages/spec/src` subdirectories, as ruled in `5902360492`
on objectstack-ai#20513. The census at the base reads 17 messages / 18 ids in 12
files. This stage rewrites 7 of them (6 titles and 1 expect message, 8
ids) in 5 files: each now states what its record decided, or drops the
number where the title already says it. The other 10 messages / 10 ids
stay, 4 because earlier stages decided they are not citations and 6
because an assertion matches the string against text this claim does not
let the stage edit; both groups are named under "What stays". Text only:
no assertion, identifier, test count, code comment, file name or
non-test file changes. No file is deferred.

## Census (re-taken first)

The instrument is stage 28's `census28.cjs`, byte-identical (md5
`31d8488b5194b8d3048e3fcaec0efaed`, the value stages 28 and 29
published): an AST walk over the `packages/spec/src` test files, one
message per folded string (a lone literal, a template, or a plus chain)
that matches the gate's id pattern, a title when the folded root is
argument 0 of a describe / it / test / suite / bench call, comments
never read. It was run against the three published readings before it
was trusted, and all three reproduce exactly: 128 messages / 130 ids in
37 files at `f7b8a5932b`, 191 / 200 in 53 files at `aa09db58c9`, 73 / 76
in 24 files at `b7e01fbbd`.

| reading | messages / ids | files |
|:--|--:|--:|
| base `11d119ab1` | 17 / 18 (titles 6 / 7, other 11 / 11) | 12 |
| stage 29's landing `0ef9029da` | 17 / 18, per file equal to the base |
12 |
| this head | 10 / 10 (titles 0 / 0, other 10 / 10) | 7 |
| the 5 edited files, this head | 0 / 0 | 0 of 5 |

Stage 29's ACCEPT carried 16 / 17 (ui 9 / 9 in 7 files, automation 2 /
3, ai 2 / 2, api 1 / 1, contracts 1 / 1, kernel 1 / 1). The base reads 1
/ 1 more, all in `ui`: the title `carries no ruling date and no tracker
id (objectstack-ai#22093)` at `view-submit-redirect-url.test.ts:341`, which PR objectstack-ai#22322
(`ad381fd94`, landed 2026-10-09T00:35Z) added after stage 29's head. So
`ui` reads 10 / 10 in 7 files, and nothing else moved. The census at
`origin/main` `e75dceddd` (8 commits past the base, none touching the 12
files) reads the same 17 / 18 in the same 12 files, so nothing regrew
while this stage ran. The reading is within one message of the claim's,
so there was no re-cut.

Per file, messages at base: `ai/build-progress` 2,
`api/meta-item-response-shapes` 1, `automation/builtin-node-config` 2 (3
ids), `contracts/approval-service` 1, `kernel/manifest` 1,
`ui/action-description` 1, `ui/component-props-unknown-members.pin` 1,
`ui/dashboard-chart-structure-refusal` 2, `ui/dashboard` 2,
`ui/notification` 1, `ui/strictness-batch14` 1,
`ui/view-submit-redirect-url` 2.

Controls:
- Pathspec: the 12 named paths hit the control word `describe(` in 12 of
12 files and a nonsense word in none; the census scanned 12 of 12.
- Planted, in a scratch tree: an id in a describe title, a plus-chain
title, an expect message, a template literal, a cross-repo spelling and
a ledger-style string each read once (6 / 6); a comment, a six-digit
colour, an HTML entity, a two-digit number and a hex colour with a
letter read 0.
- Lit and dark inside the group: the 5 edited files read 1, 2, 1, 1 and
2 messages at base and 0 at the head; the 7 untouched files read the
same at both ends.

## Deferral

At the census (2026-10-09T03:03Z) 17 PRs were open; at the re-scan
before opening this PR (04:13Z), 13. Every file list was read through
REST (605 and 593 rows). None touches any of the 12 files: lit control
`api/protocol.test.ts` (PR objectstack-ai#22323) found, dark control 0. Of the four
PRs the claim named, objectstack-ai#22380 has landed and objectstack-ai#22315, objectstack-ai#22323 and objectstack-ai#22215 are
open; none of them touches a file in this group. Deferred files: none.

## What changed

7 literals, one line each, in 5 files: +7 / -7. Every file keeps its
line count.
- `api/meta-item-response-shapes.test.ts:226`: the `[objectstack-ai#22114] ` prefix
goes; the title already says what objectstack-ai#22126 landed, that the read serves
the version token and the 409 carries the current one as data.
- `automation/builtin-node-config.test.ts:434`: "a CEL envelope beside
literals; the `{token}` dialect retired". objectstack-ai#14149's ruling A made an
assignment value a CEL envelope beside literals, and objectstack-ai#19939 retires the
`{token}` dialect in flow value slots; the title already stated both, so
only the two numbers go.
- `automation/builtin-node-config.test.ts:485`: the `[objectstack-ai#19939] ` prefix
goes from the REFUSES title.
- `kernel/manifest.test.ts:681` and `ui/action-description.test.ts:235`:
the trailing `(objectstack-ai#22093)` goes. objectstack-ai#22093 decided that author-visible help
and refusals carry no service-interface name, ruling date or foreign
example id, and both titles already say what their bodies pin.
- `ui/view-submit-redirect-url.test.ts:341`: the trailing `(objectstack-ai#22093)`
goes from the title.
- `ui/view-submit-redirect-url.test.ts:118`: the expect message `states
the rule, not its ruling date (objectstack-ai#22093)` drops the number. It is an
assertion's failure message, so it was needle-checked first (below) and
is the one declared non-title string.

All seven are "drop a number the title already explains". None needed a
rewrite in new words, because each title already carried the decision.

## What stays, and why

10 messages / 10 ids in 7 files, none edited.

Four are CSS hex colours, not citations. `colors: ['objectstack-ai#111', 'objectstack-ai#222']` at
`ui/dashboard-chart-structure-refusal.test.ts:94` and `palette: ['objectstack-ai#111',
'objectstack-ai#222']` at `ui/dashboard.test.ts:124` are fixture input the schema
under test reads. Stage 21's ACCEPT (`6001279159`, decision A) kept them
by file and line, and every later stage carried them forward.

Six are strings that an assertion matches against text outside this
stage's edit surface. Moving one at the same strength means editing a
non-test source docblock (and, for the first two, its generated
reference page) or the assertion that matches it. The claim forbids both
and says to stop and report, so none is touched; `open_questions` in the
report carries the decision.
- `ai/build-progress.test.ts:236` `'cloud#2172'` and `:237`
`'objectui#7388 block 2'`: `toContain` over the source text of
`ai/build-progress.zod.ts` (docblock lines 8, 27 and 85), which
`content/docs/references/ai/build-progress.mdx` renders.
- `contracts/approval-service.test.ts:274` `'objectstack-ai#16495'`: `toContain` over
the docblock above `continueRestoredRun` in
`contracts/approval-service.ts` (line 999).
- `ui/notification.test.ts:123` `'// [objectstack-ai#4610]'`: the locator of the
tombstone note in `ui/notification.zod.ts:94`; the file's own
`toMatch(/^\[objectstack-ai#4610\]/)` at `:134` reads the same note.
- `ui/strictness-batch14.test.ts:395` `'objectstack-ai#5015'`: `toContain` over
`ui/notification.zod.ts` and `ui/sharing.zod.ts`.
- `ui/component-props-unknown-members.pin.test.ts:322` `ruling:
'decision card objectstack-ai#21704, fork 4, letter B (record 5979239990)'`: the
file's own assertion at `:417` matches the value with `/objectstack-ai#21704/`. Stage
20's ACCEPT (`5998488373`) kept it for this reason and sent it to the
needles' stage.

Readers of the seven rewritten strings: none. `git grep -F` at HEAD over
the tracked tree outside the 12 files, with the full literal, a
24-character window around each id, and the text on each side of each id
(29 needles over all 17 sites): the only hits are the readers of the
kept strings named above, the lit control (`composeStacks` in
`stack.zod.ts`) hits and the dark control does not. The same needles
searched inside the 12 files, outside each literal's own span: the only
hits are two code comments beside `:322`. The five short needles
(`cloud#2172`, `objectstack-ai#16495`, `// [objectstack-ai#4610]`, `objectstack-ai#5015`, `objectstack-ai#21704`) fall under the
script's 12-character floor, so their readers were confirmed by direct
`git grep -F` with a dark control.

## Cited records

Read with their comments as the API serves them: objectstack-ai#22114 (8 of 8
comments; landed as PR objectstack-ai#22126), objectstack-ai#14149 (12 of 12; ruling A `5507504961`,
landed as PR objectstack-ai#15113), objectstack-ai#19939 (15 of 15; pass 1 landed as PR objectstack-ai#22259, the
card stays open), objectstack-ai#22093 (17 of 17; PRs objectstack-ai#22125, objectstack-ai#22309 and objectstack-ai#22322), and
the four PRs themselves. objectstack-ai#19939 is still open: its pass 1 refuses the
`{token}` dialect in flow value slots and keeps two spellings (the date
macros and `{$User.*}`) until CEL can write them. The describe's
PRESERVATION test still accepts those two, and the title keeps the
record's own verb, "retired", as PR objectstack-ai#22259 wrote it. The title and the
test body say the same thing the record says.

## Verification

At head `8885dbf1c` (one commit on base `11d119ab1`):
- **Text only.** `textonly28.cjs` (stage 28's, md5
`957eff6b3837d762b8e03d070155930a`) on all 12 base copies against their
heads: 12 / 12 SAME. 7 changed tokens, as predicted in writing at
2026-10-09T03:08Z before any edit or test run: 6 titles and 1 declared
string (`--declared 118`). Every other string token, identifier, number,
punctuation mark and comment is byte-equal. 16 controls, expectations
written in the script before the first run, 16 / 16 as predicted: an
identifier rename, a numeric literal, a comment edit, an undeclared
expect message, a rewritten title given a new id, an id-free title
edited, one title reverted to base (SAME, 0 changed), a declared label
without `--declared`, a declared line plus another changed string, the
declared line alone (SAME, 2 changed), a title re-split into a plus
chain, a test added, an untouched file (SAME, 0 changed), an id appended
to a rewritten title, a kept needle rewritten, a kept hex colour
rewritten. The two controls that mutate a string beside the declared
line fail at the mutated line, not at 118.
- **Tests, 12 files, base and head.** `--project local --project repo`
with the JSON reporter, 618 tests in 88 suites each side, all passed.
Per-file test count and status sequence identical in 12 / 12. 23 full
names changed (4 + 14 + 1 + 3 + 1), 0 mismatches against the plan. Names
carrying `#` plus digits: 23 at base, 0 at head. Duplicate full names: 3
and 3, the same three `[object Object]` it.each rows at both ends.
- **Full spec unit tier at the head**, under the verify lock: `Test
Files 626 passed (626)`, `Tests 18743 passed | 1 todo (18744)`.
- **Build and typecheck**, under the verify lock: `turbo run build` over
`packages/*` and `packages/*/*`, `Tasks: 71 successful, 71 total`;
`@objectstack/spec` `typecheck` exit 0 with `check:test-typecheck`
holding 52 files / 246 errors / 135 pinned signatures, the same figures
as stage 29; the 12 files are all in the `tsconfig.test.json` program.
- **Gates.** `dispatch-gates.mjs --commands` at the head derives 79
(stage 29's 77 plus `check:authorable-surface` and
`check:yaml-examples`); all 79 exit 0, and `--ran` reconciles 79
derived, 79 run, 0 NOT-MEASURED. The five artifact-roster families that
keep their roster in a directory one of the paths is in
(`check:meta-url-spelling`, `check:spec-changes`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`) and `check:generated` (all 15 artifacts up
to date) also exit 0.
- **ESLint**, `--no-inline-config`, 12 files: 0 errors, 0 warnings.
Population from ESLint's own config: 12 configured, 0 ignored, 0 with a
type-aware parser option, so this diff cannot move the verdict of a file
it does not touch.
- **Skip-changeset.** `npm pack --dry-run --json --ignore-scripts` in
`packages/spec`: 2069 files, 0 `*.test.ts`, 0 of the 5 edited files;
controls `src/stack.zod.ts`, `dist/index.mjs` and `package.json`
present. The rewritten expect message occurs in 0 files of `dist/`; the
control `Unrecognized key` occurs in 42. Nothing published changes.
- **Governed.** `check-governed-merges.mjs --test` on the 5 paths: 0 of
5, not governed; 14 changed lines.
- **Merge.** `git merge-tree --write-tree` onto `origin/main`
`e75dceddd`: clean.
- **Bytes.** 0 added lines carry `#` plus digits; 0 control bytes in the
changed files.

Declared narrowing: the 12-file base and head comparison ran outside
`os-verify-lock.sh`, after three queue turns (about 28 minutes) ended
without a grant. It is a 12-file run with two workers; the workspace
build, the typecheck and the full unit tier all ran under the lock. The
gates are `check:*` runs, which do not use the lock.

## Acceptance notes

- **Regrowth continues.** Since stage 27's landing, four PRs (objectstack-ai#22125,
objectstack-ai#22126, objectstack-ai#22259 and objectstack-ai#22322) added 7 messages / 8 ids to test strings in
files that already existed, one of them to a title objectstack-ai#22322 wrote while
stripping a ruling date from a describe. Test files sit outside
`check:doc-authoring`'s ledgered leg, and the ruling adds no gate, so
the per-stage census is the only instrument. An observation about the
burn-down's denominator, not a class a / b / c finding.
- **Comments are untouched.** Code comments in these files still cite
ids (for example `// ─── assignment (objectstack-ai#14149) ───` at
`builtin-node-config.test.ts:432`); comments are objectstack-ai#20234's share.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_

Co-authored-by: Claude <noreply@anthropic.com>
claude added 2 commits October 9, 2026 09:13
The merge of origin/main kept the branch's side of the generated
content/docs/references/api/protocol.mdx and dropped main's: the
submitBehavior description no longer carries a ruling date. Regenerated
from the merged source after taking main's side, so the page now carries
both main's description and this branch's _drafts label rows.

Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 98a5aef8dcd71f0cd0c5ac6a866b1c133df06866
Local-runs: none

Inputs read, at 2026-10-09T11:07Z: card #22200 (body and all 6 comments: triage 6054566832, claim 6060635187, dev reports 6065161975 and 6065768152, the merge report 6079486284, the seat's accept 6065790260); PR #22323 (body, the 8-file list, its 7 commits, the net diff against main at da159f7 — the merge base of this head — +311/−17; its 3 issue comments, among them the earlier record 6065980475 on 81cd929; 0 reviews, 0 review comments); the 35 check-runs on the head. Read through git on fetched refs (refs/review/pr-22323-b, refs/review/pr-22323-main) and the REST API; nothing built, run or re-run. This head is a merge of main at da159f7 into the head the earlier record judged, plus one regeneration commit; it is judged on its own below.

① Derived judgments

  1. The PR's own delta is unchanged. The added and removed lines of 28bff18d0..81cd9291b (the delta the earlier record judged) and of da159f74e..98a5aef8d (this head's) are identical: 328 lines each, a line-set diff of 0, the same 8 files. The merge commit 6247b16 carries exactly those 8 files against main and nothing else. So every judgment in 6065980475 ① items 1–9 stands on this head as written: the required-nullable label on ListDraftsResponseSchema rows (protocol.zod.ts), the producer draftBodyLabel reading the body through storedRowBody and judging it with I18nLabelSchema.safeParse (null on refusal and on a parse throw, never row.name), the protocol pass-through with its declared label: I18nLabel | null, the seven-key disclosure pins, and the untouched REST, runtime and SDK faces. Right.
  2. main's feat(metadata-protocol)!: managed content is sealed — OS_METADATA_WRITABLE no longer opens an item a managed package ships (ADR-0131 D6, #15206 S2) #22401 (the managed-content seal) does not interact with what this PR publishes. Its hunks in sys-metadata-repository.ts are the import at :91 (managedItemSealedSentence in, packagedBaseRegimeSentence out) and the assertAllowed write door at about :1693–:1990; its hunks in protocol.ts sit at :44, :150, :11230, :15972–:17357, :20195–:20884 and :26657. None names listDrafts, storedRowBody, draftBodyLabel, rowToItem, lockHead or the _drafts route. At the head the PR's import (:84, I18nLabelSchema and I18nLabel from @objectstack/spec/ui) and main's (:91) stand adjacent; draftBodyLabel is at :223 and listDrafts at :1350–:1390, both byte-identical to the judged delta; protocol.ts keeps its I18nLabel import at :154 beside main's applyConversionsToFlow edit on the neighbouring line, and listDrafts at :22839. The seal judges WRITE intents (override-artifact falls through to the seal; runtime-only alone opens on the hatch) and is not conditioned on state: its reach on drafts is which draft rows can come to EXIST (a draft overlay onto an item a managed package ships is refused at the door), never what a listed row carries. The label still comes off the row's own body. Right — no interaction. Likewise main's rest-server.ts hunks (the auth: server-side auth.api.getSession reads renew the session without forwarding the renewed cookie, so the browser cookie expires before the session (split session) #22258 in-process session-read rule at :40 and :3023–:3220) leave the _drafts handler alone, and ui/i18n.zod.ts, runtime/src/domains/meta.ts, client/src/index.ts, the conversions registry and sys-metadata.object.ts did not move between the two bases.
  3. The regenerated reference page is right. content/docs/references/** is merge=os-regen (.gitattributes:178). main's fix(spec): the submitBehavior property help carries no ruling date #22322 removed the ruling-date clause from the two submitBehavior help rows (protocol.mdx about :1746 and :1831). The merge commit 6247b16 kept the branch side of the page byte for byte (its diff against 81cd929 on that path is empty), so against main it carried 7 page lines: the PR's 3 plus that 2-row regression. The regeneration commit 98a5aef8d changes one file, that page, and exactly those two rows, restoring main's text. At the head the page differs from main by exactly the PR's own 3 lines (+2/−1): the drafts summary row and the nested-shape label row. gen:docs output, not hand-written; TypeScript Type Check, which holds check:docs and every other spec artifact gate, is green on the head. fix(spec): the submitBehavior property help carries no ruling date #22322's other page, references/ui/view.mdx, is untouched by the PR and took main's side with no second parent to drop. Right.
  4. Accept set and public surface, restated for this head: one member added to a published response schema, required and nullable; one producer this repo owns widened to carry it; no export added, removed or renamed (api-surface/ unmoved, as its gate agrees); no route, query parameter or SDK method changed; no governed path in the file list. Every reader gains a key, and nothing a reader could send is refused that was accepted before. Right.

② Semver level

Changeset .changeset/22200-drafts-header-label.md, unchanged on this head: @objectstack/spec minor, @objectstack/metadata-protocol minor, body Clause-②: yes (widening); the PR body carries Clause-②: yes (widening: a new field on a published response schema); the claim says the same. Matches what the diff publishes: additive for every reader, the only narrowing (the key is required) landing on the producer this repo owns. yes takes at least minor — met; no (narrowing) arm, no removal or rename, so no migration text and no ADR-0087 disposition is owed; skip-changeset does not apply and is not applied. main's #22401 ships its own breaking changeset for metadata-protocol; that is main's declaration, not this PR's, and the two do not overlap. Check Changeset is green on the head.

Clause-②: yes (widening) — agreed.

③ Boundary flags

  • open_questions: [] in all three dev reports. Nothing to answer.
  • Merge-round notes (6079486284), each answered: (a) main moved past da159f7 after the merge (to 3ca71b6; fix(metadata-protocol)!: one reader of OS_METADATA_WRITABLE — the legacy OBJECTSTACK_METADATA_WRITABLE, removed in 11.0, no longer opens the hatch at the type listing #22440 touches both metadata-protocol files at lines far from the PR's hunks) — not an input here; the head's own check-runs ran on the merge ref into 440bed6 (the drift check's tree line), GitHub reads the PR as mergeable_state: clean, and the queue rebuilds against the main of the moment — the §10 second round, not this record's. (b) The merge commit message names the local branch with an -m81 suffix — cosmetic, history not rewritten. (c) A scratch file overwritten in the dev's own scratch area — not a contract matter. (d) Draft flag, body, labels and assignee untouched — consistent. The gate record was re-executed from scratch after a denied text edit rather than edited — the right way round.
  • out_of_scope_findings: round 3 adds one, carrier none: os-regen-merge.sh's refusal text reads circular when the pre-commit hook refuses step 3 — polish, outside the filing classes; noted, and this round followed the script's documented collection point regardless. Rounds 1–2's two (an analytics_cube draft reads label: null because CubeSchema spells its display name title; an empty-string label carried as '') and the earlier record's empty-map sibling stay as acceptance notes.
  • The earlier record's escalation is closed on this head: Test Core (6/6) and the aggregate Test Core are both success.
  • Check-runs on the head, all 35 read in this act: 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in), both path/opt-in), 0 failure, 0 pending. The seven required contexts are each success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. No red, no pending.
  • No governed surface in the file list (none of docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md, CLAUDE.md); Governed Surface Queue Guard green.

Implemented-by: claude/issue-22200-drafts-header-label
Reviewed-by: session_01DhTqaEHqPVSVnAkjG3jywn

VERDICT: PASS

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/m tests tooling

Projects

None yet

2 participants