Skip to content

feat(metadata-protocol): mark an erased authoring arm in the /meta/types derivation - #19399

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-19295-meta-types-erased-authoring-mark
Sep 20, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-19295-meta-types-erased-authoring-mark

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Part of #19295

Clause-②: no

What this lands

GET /meta/types now annotates a served member whose AUTHORING arm the output derivation erased, so a consumer can tell an erased authoring type from a member that genuinely admits anything.

Every predicate slot the platform serves composes the expression-input family — a two-arm union whose string arm is a ZodPipe. The served derivation is zod's default io: 'output', which describes what comes OUT of the transform, so the arm's own input type is erased and the member is served with an empty first arm. On the wire that empty schema means "admits everything", so a metadata designer could not tell the husk from a member that really does accept any instance.

Each such husk arm now carries one vendor-prefixed keyword:

{ "x-objectstack-erased-authoring-input": { "version": 1, "type": "string" } }
  • Declared once, in packages/metadata-protocol/src/erased-authoring-mark.ts, with the keyword, the shape version and the reader a consumer mirrors.
  • Versioned inside the VALUE, not baked into the keyword name, so a consumer gates on a shape it understands rather than on mere presence.
  • The consumer reads the mark, never the key name — nothing here asks objectui to match hook.condition / visibleWhen / the rest.

Clause-② — how it was measured

The claim comment told this seat to measure the declaration rather than carrying one, and the card's Acceptance sets the rule: a new declared key on the ADR-0089 envelope is yes; an annotation on the served projection only is measured.

The shape chosen is the annotation, and the diff is the measurement. Seven files, all in packages/metadata-protocol/src/, .changeset/ and scripts/engine-double-contract.pinned.json. packages/spec is not touched: no Zod object gained a key, no closed set gained a member, no published entry point's export listing grew, and no registry gained a row. packages/metadata-protocol/src/index.ts is deliberately unchanged — the mark travels on the WIRE, so objectui reads it from the payload and no new published export exists for it to import.

Why the envelope key was the wrong shape here, and not merely the more expensive one. A declared key on the envelope describes the OBJECT arm, while the thing that needs describing is the sibling arm the derivation erased. It would also have been carried by every consumer of that envelope everywhere it is embedded, including the persistence contract where nothing was erased at all. The annotation sits on the exact node whose meaning is ambiguous, and it is emitted by the projection that created the ambiguity.

The refused widening is untouched

The blanket io: 'input' repair stays refused and its pin stays armed. JSON Schema ignores an unrecognised keyword, so every served document accepts exactly what it accepted before — byte-different, semantically identical. adds no constraint — every marked arm still admits every instance asserts that structurally: read with its mark removed, every marked node is still the empty schema zod emitted.

Two pin baselines now derive through the emitter's OWN override, which is the treatment #17502 established for the unauthorable strip rather than a new one:

  • protocol.meta-types-degenerate-derivation.test.ts — blast radius still reads exactly ['action'].
  • protocol.meta-types-unauthorable-columns.test.ts — the served payload is again a derivation minus deletions and nothing else.

Reverse verification, both legs restored and proven against HEAD. Adding io: 'input' to the served derivation and re-running the degeneracy pin moves 24 types instead of one and reds it — the refusal is still armed, not laundered into a baseline. Mutation and restore went through scripts/ablation-replace.mjs, so the anchor count, the blob change and the post-restore git diff HEAD are on-disk evidence rather than an exit code.

The negative control

A mark that appeared on every empty schema would be exactly as uninformative as the husk it replaces, so both directions are pinned and the negative half is the load-bearing one.

Measured over the served surface: 78 marked arms across 8 types, and 187 empty-schema nodes left unmarked. Named instances are pinned rather than only counted — the ADR-0089 envelope's own ast (a z.unknown() sitting one level BELOW a marked arm, inside the very envelope that arm is a sibling of), field.defaultValue, and the open record values on tool.parameters.

action is the second control, from the other direction: it is the one type served from the io: 'input' retry, where a pipe derives from its input side, nothing is erased, and the predicate slot already publishes its real string arm. It carries zero marks, and the test also asserts the string arm is really there — so the absence reads as "nothing was erased", never as "the sweep missed it".

Reverse verification of the control. Making the emitter treat every husk as an erased pipe arm reds BOTH negative assertions — the ast pin and the action pin — so neither is vacuously true.

Premise re-derived on this tree, with two corrections

The card's measurement came from objectui's dev reports, seat-verified there. Re-derived here against origin/main, and it holds — with two things the card states differently:

  1. The predicate slots compose EvaluatedExpressionInputSchema, not ExpressionInputSchema. The card names the latter. On this tree hook.condition, field.visibleWhen / readonlyWhen / requiredWhen and the flow edge.condition are all EvaluatedExpressionInputSchema since spec/automation: FlowEdgeSchema.condition still accepts an envelope the engine cannot evaluate (ast-only, whitespace-only source) — the evaluated-slot rule of #15430 has not reached the edge condition #15807 / spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every other ExpressionInputSchema slot an engine evaluates (formula expression, validation / hook / sharing condition, visibleWhen…) still accepts an ast-only or blank-source envelope #15811; ExpressionInputSchema and its alias PredicateInputSchema remain the PERSISTENCE contract. It changes nothing structural — both are two-arm unions whose first arm is a ZodPipe over a string, both erase identically — but the mark is emitted by a structural predicate rather than by naming a schema, so it covers the whole family and a third sibling added later.
  2. sharing_rule resolves no schema in the registry-only harness, so that member is covered by the structural sweep wherever it IS served rather than pinned by name.

One consequence worth naming because it is wider than the card's list: the same predicate also marks flow.nodes.items, which derives an empty schema today and whose erased authoring type is an object. That is the same defect one level up, and the mark now says so instead of serving an anonymous husk. Nothing else about that member moves.

Acceptance notes

  • The documentation half of Acceptance item 3 is NOT in this PR, and the card stays open for it. content/docs/references/ is auto-generated from packages/spec TSDoc and may not be hand-edited; the hand-written content/docs/api/metadata-api.mdx does not document the /meta/types schema derivation at all. Both homes are outside this claim's declared file surface (packages/metadata-protocol/src/), so the seat routes that half rather than this lane taking it uncleared.
  • What the mark enables: a consumer can now offer a condition builder for an erased predicate arm instead of vetoing every empty schema. It becomes installable by objectui when a release carries it, which is a separate step from this merge.
  • Noted, not filed: flow.nodes.items serving an empty schema is a whole-member husk of the metadata-protocol: /meta/types serves an EMPTY JSON Schema for action — the output-mode derivation of its ZodPipe has no properties, and the hand-crafted fallback never fires #17501 class, one level below the type. It is now labelled by the mark; making it derive is a different change with a different blast radius. Carrier: no queued PR touches it today.

Verification

  • pnpm --filter @objectstack/metadata-protocol test — 183 files, 2608 passed, 19 skipped, 0 failed.
  • pnpm --filter @objectstack/metadata-protocol typecheck — clean.
  • pnpm --filter '@objectstack/metadata-protocol^...' build — dependency closure green.
  • Gate families derived with scripts/pm/dispatch-gates.mjs and reconciled with --ran: 68 derived, 65 run green, 3 NOT MEASURED (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each exits 3 refusing its own prerequisite, a full pnpm build; CI's Build Core provides it).
  • pnpm lint — the whole repo, eslint . --no-inline-config, exit 0 at 03809f097.

Generated by Claude Code

…pes derivation

The served output derivation of a `ZodPipe` describes the transform's result,
so a predicate slot's bare-string authoring arm is emitted as the anonymous
`{}` — indistinguishable on the wire from a member that admits every instance.

Annotate exactly those husks with one vendor-prefixed keyword,
`x-objectstack-erased-authoring-input`, carrying a shape version and the
authoring type that was erased. The predicate is structural: the node admits
everything, the zod node behind it is a pipe, and the pipe's input side names a
type. A `z.unknown()` husk (the envelope's own `ast`) is not a pipe and stays
bare, and a type served from the `io: 'input'` retry has erased nothing and is
marked nowhere.

No keyword zod emitted is rewritten, so what each document accepts is
unchanged and the refused blanket `io: 'input'` widening stays refused. The
degeneracy pin's baseline derives through the same hook, the way #17502
established for the unauthorable strip, so its blast radius still reads one.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
…h the same hook

`protocol.meta-types-unauthorable-columns.test.ts` asks whether the served
payload is a derivation MINUS unsatisfiable nodes and nothing else. The
erased-authoring mark is an addition, so eight types read as unexplained
against a baseline derived without it — red for a reason that pin does not own,
and blind meanwhile to the over-drop it exists to catch.

Same treatment #17502 gave the strip: the baseline derives with the emitter's
own `override`. Any other addition or rewrite still lands in `other`, and every
removal is still judged by `acceptsNothing`.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
…erased-authoring mark

The new suite stands up a fake engine over the producer's own write-verb
dispatch assertions, so `check:engine-double-contract` wants its three rows in
the retained ledger or the file is never protected by it.

Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 13 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx (via readOnly (literal, a string literal in ANNOTATION_ONLY_KEYWORDS))
  • content/docs/plugins/adding-a-metadata-type.mdx (via readOnly (literal, a string literal in ANNOTATION_ONLY_KEYWORDS))

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

  • content/docs/releases/v15.mdx (via readOnly (literal, a string literal in ANNOTATION_ONLY_KEYWORDS))

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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 11 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 c5d3d1d8b69e84497373c5232a654f0bbffe2b76 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6e471b76653c06d04525bc497b7531e12560caad — the merge of head 03809f0972ed908aaf65625af58395f91e086ca3 into base c5d3d1d8b69e84497373c5232a654f0bbffe2b76, 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 6e471b76653c06d04525bc497b7531e12560caad && git checkout 6e471b76653c06d04525bc497b7531e12560caad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c5d3d1d8b69e84497373c5232a654f0bbffe2b76 03809f0972ed908aaf65625af58395f91e086ca3 && git checkout -B drift-repro c5d3d1d8b69e84497373c5232a654f0bbffe2b76 && git merge --no-ff 03809f0972ed908aaf65625af58395f91e086ca3

node scripts/docs-audit/affected-docs.mjs --json c5d3d1d8b69e84497373c5232a654f0bbffe2b76

⚠️ 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 c5d3d1d8b69e84497373c5232a654f0bbffe2b76 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@huangyiirene
huangyiirene marked this pull request as ready for review September 20, 2026 16:45
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 0870fb5 Sep 20, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-19295-meta-types-erased-authoring-mark branch September 20, 2026 17:07
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants