Skip to content

feat(spec,lint)!: accept ADR-0109, remove agent.tools[], lint agent authoring, model AI exposure in tool resolution (#3820) - #3894

Merged
os-zhuang merged 4 commits into
mainfrom
claude/agent-metadata-positioning-th5hhm
Jul 28, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/agent-metadata-positioning-th5hhm

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Completes the #3820 positioning work: ADR-0109 is accepted, the two structural holes it left open are closed, and a bug in the rule merged by #3885 is fixed.

1. ADR-0109 → Accepted — implemented (Phase 1)

Evidence recorded in the ADR: the registry + reference rule (#3885), conformance tests in cloud (objectstack-ai/cloud#908), and the first app ported to the model — objectstack-ai/hotcrm#512, which went from 16 tool references with 10 dead to 22 references, 0 findings.

2. Fix: the rule blessed references the agent can never call

validate-ai-tool-references resolved action_<name> against every declared action. The runtime is far stricter (ADR-0011): a tool materialises only when the action

Requirement
opts in ai.exposed: true and ai.description
has a headless path type script / api / flow — url / modal / form are UI-only
is dispatchable a target (or a body, for script)

The permissive version therefore produced a false pass — the exact failure the rule exists to catch, one layer down. Found while porting HotCRM, where 3 of the 5 actions its skills wanted are type: 'modal'; the rule had already green-lit my own rewrite before I checked the runtime.

Unresolved action_* names now get a distinct message and fix, because "the action exists but isn't exposed" and "the name is fictional" need different answers. The hint says plainly that a modal action staying human-driven is a design answer, not a gap.

3. Breaking: agent.tools[] removed

ADR-0064's invariant is "an agent's tool set is the union of its surface-compatible skills' tools; nothing falls through to the global registry" — and this legacy inline slot was the one seam that broke it. The runtime resolved agent.tools[].name against the full registry with no surface check, so an ask-surface agent could name an authoring tool and get it. Removing the field makes the invariant structural — there is no second slot to disagree with the skills — rather than a rule every reader has to remember (ADR-0049 design+enforce-or-remove).

AIToolSchema and the AITool type go with it; the json-schema manifest key is deregistered in the same PR, as that gate requires. Authoring tools remains a silent no-op rather than a parse error, so existing stacks keep parsing. Migration: attach capability through skills.

4. New rule: validate-ai-agent-authoring (warning)

ADR-0063 §2 withdrew tenant agents and the runtime enforces it on both paths (catalog filter + objectstack-ai/cloud#904's load gate) — but defineStack still accepted the array, so an app could ship agents that parse, validate, and never run. HotCRM did, for months. This is the missing authoring-time signal (ADR-0078: loud at the producer, tolerant at the consumer); the runtime remains the gate, which is why it warns rather than errors — a platform package legitimately authors agent records and the rule can't tell which is which from the stack alone. Joins REFERENCE_INTEGRITY_RULES.

5. Docs teach the default path

content/docs/ai/agents.mdx gains a worked Action → skill example with no defineTool anywhere, the three conditions above as a table, and the point that naming reasoning ("analyse the pipeline", "score this lead") as a tool is the most common authoring mistake — it produces an assistant that claims abilities it does not have.

Verification

  • @objectstack/spec: 262 files / 6817 tests · tsc --noEmit clean · check:docs, check:skill-refs, check:react-blocks, check:skill-examples in sync · check:api-surface correctly flagged both removals as breaking before the snapshot was regenerated (changeset is major).
  • @objectstack/lint: 37 files / 540 tests (11 for the exposure predicate + 6 for the new rule).
  • @objectstack/cli: 72 files / 737 tests — both new rules reach validate/lint/compile through the suite.
  • Full workspace pnpm build green.

Refs #3820 · #3885 · ADR-0109 · ADR-0064 · ADR-0011 · ADR-0078 · ADR-0049

🤖 Generated with Claude Code

https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4


Generated by Claude Code

…uthoring, model AI exposure in tool resolution (#3820)

Completes the #3820 positioning work. ADR-0109 is now Accepted —
implemented (Phase 1), with evidence: the registry, the reference rule,
cloud#908's conformance tests, and hotcrm#512 as the first app ported to
the model (22 tool references, 0 findings, down from 16 with 10 dead).

BREAKING — `agent.tools[]` is removed. ADR-0064's invariant is "an
agent's tool set is the union of its surface-compatible skills' tools;
nothing falls through to the global registry", and this legacy inline
slot was the one seam that broke it: the runtime resolved
`agent.tools[].name` against the FULL registry with no surface check, so
an `ask`-surface agent could name an authoring tool and get it. Removing
the field makes the invariant structural rather than a rule every reader
must remember (ADR-0049 design+enforce-or-remove). `AIToolSchema` and the
`AITool` type go with it; the json-schema manifest key is deregistered in
the same PR, as its gate requires. Authoring `tools` stays a silent
no-op, not a parse error, so existing stacks keep parsing.

`validate-ai-tool-references` now models AI exposure. It resolved
`action_<name>` against every declared action; the runtime is stricter
(ADR-0011): a tool materialises only for an action that opts in with
`ai.exposed` + `ai.description` AND has a headless path (script/api/flow
with a target or body — url/modal/form are UI-only). The permissive
version blessed references the agent could never call — the exact failure
the rule exists to catch, one layer down. Found while porting HotCRM,
where 3 of the 5 actions its skills wanted are `type:'modal'`. Unresolved
`action_*` names now get their own message and fix, because "not exposed"
and "fictional" need different answers, and the hint says plainly that a
modal action staying human-driven is a design answer, not a gap.

New rule `validate-ai-agent-authoring` (agent-authoring-withdrawn,
warning): flags a stack declaring `stack.agents`. ADR-0063 §2 withdrew
tenant agents and the runtime enforces it on both paths, but
`defineStack` still accepted the array — so an app could ship agents that
parse, validate, and never run (HotCRM did, for months). This is the
missing authoring-time signal; the runtime remains the gate.

Docs: `content/docs/ai/agents.mdx` now teaches the zero-tool-record
default path with a worked Action → skill example, the three conditions
that decide whether `action_<name>` exists, and why naming reasoning
("analyse the pipeline") as a tool is the most common authoring mistake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4
@vercel

vercel Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 28, 2026 3:24pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests protocol:ai tooling size/l labels Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/platform-objects, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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.

claude added 3 commits July 28, 2026 15:11
…rd, ADR-0059)

`check-doc-authoring.mjs` fails any bare metadata literal in a doc code
block — the new Action → skill example wrote `export const X: Action = {}`.
Switched to `defineAction({ ... })` with its import, which is the pattern
the guard exists to teach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4
The prose-example gate caught two `os:check`-marked agent examples still
authoring `agent.tools` after this PR removed the field; three more
unmarked examples on the same page taught it too. All five now express
capability through `skills`, which is the point — these are examples an
AI copies verbatim, so leaving them on a slot that no longer exists would
have taught the removed pattern to the next generated app.

Each rewrite also names WHERE the tools come from (platform data tools,
`action_<name>` from an `ai.exposed` Action, `search_knowledge`), so the
examples teach the ADR-0109 default path rather than just dropping a
field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4
…behind

`check-i18n-bundles` flagged 4 drifted platform-objects bundles: the
metadata-form translations still carried a `tools` entry ("Tools" /
"Herramientas" / "ツール" / "工具", each with the "legacy mode" helpText)
for a form field this PR removed. Regenerated with
`scripts/check-i18n-bundles.mjs --write`; the diff is exactly those four
deletions, one per locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4
@os-zhuang
os-zhuang marked this pull request as ready for review July 28, 2026 16:02
@os-zhuang
os-zhuang merged commit e2616e0 into main Jul 28, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/agent-metadata-positioning-th5hhm branch July 28, 2026 16:02
os-zhuang added a commit that referenced this pull request Jul 28, 2026
#3894 follow-up) (#3904)

#3894 removed `agent.tools` and `AIToolSchema` outright, which broke
`pnpm --filter @objectstack/spec build` on main: the authorable-surface
ratchet (ADR-0104 / #3733) fails when an authorable key disappears,
because none of these schemas is `.strict()` — Zod silently STRIPS an
unknown key, so an author who keeps writing `tools:` gets a clean parse
and an agent that reaches none of the tools they listed. That is the
silent-capability-loss shape #3820 exists to eliminate, restored one
layer down. The gate was right; my removal was wrong.

(It slipped CI because Build Core restored a turbo cache entry for the
spec build; the failure reproduces on any cold build of main.)

The removal stands — ADR-0064 needs the second, unscoped tool slot gone.
What changes is HOW it is removed:

- `agent.tools` is now `retiredKey()`, so authoring it throws with the
  fix in the message (use `skills`; a platform tool by name, or
  `action_<name>` for your own AI-exposed Action; `os migrate meta`).
  This supersedes #3894's "remains a silent no-op rather than a parse
  error" — loud is correct, and is what the ratchet requires.
- A D2 conversion `agent-tools-to-skills` + its D3 chain step, so the
  removal reaches spec-changes.json, the upgrade guide and the
  `spec_changes` MCP tool. Unlike the protocol-17 renames beside it this
  has NO lossless target: each entry must become a reference inside a
  skill, which is a human decision. So it drops the dead key (the runtime
  stopped reading it in cloud#910, so it already contributes nothing) and
  emits one notice per agent marking where capability must be
  re-declared.
- The three `ai/AITool:*` baseline lines are deleted deliberately — the
  one case the ratchet sanctions in-PR. They were authorable only as the
  element shape of `agent.tools`; with the parent tombstoned nothing
  reaches them, so they cannot vanish silently: the parent speaks first,
  with a prescription. Keeping a schema alive purely to hold three
  unreachable lines would be the dead contract surface this issue is
  about.

Agent tests now pin the rejection and its message rather than the strip
semantics they asserted before.

Verified: spec build OK, spec 6823 tests, lint 540, cli 761, full
workspace build, doc-authoring guard, check:docs, check:api-surface,
check:skill-refs, check:skill-examples (197), eslint — all green.


Claude-Session: https://claude.ai/code/session_01BHjroNkLkajskKbJaidko4

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 4, 2026
… that keeps them there (objectstack-ai#4917) (objectstack-ai#5031)

`snippets/objectstack.json` is a metadata PRODUCER — whatever `os-view-grid`
expands to is the first `.view.ts` an author (human or AI) ever writes — and
nothing in this repo had ever parsed that output. So the snippets drifted out
of the spec in silence. An audit of all eight found five broken:

- os-view-grid: `list.defaultSort` / `list.pageSize` (never declared on
  ListViewSchema), plus `type` / `objectName` on the CONTAINER, which is the
  flat-view-where-a-container-goes mistake ViewSchema's own guidance names.
  Now `defineView({ object, list: { sort: [{field, order}], pagination:
  { pageSize } } })`.
- os-flow: node `name` / `next` and a top-level `trigger` block. Now
  `defineFlow` with the binding on the START node's config and explicit edges.
- os-agent: `tools`, removed in protocol 17 (objectstack-ai#3894). Now `skills`.
- os-stack: manifest missing the required `id` / `type`.
- os-field-lookup: `reference: { object, labelField }`; `reference` is a plain
  object name, the label field is `displayField`.

Separately all five module snippets imported `{ Data }` / `{ UI }` /
`{ Automation }` / `{ AI }` from the package root. Those namespace re-exports
were removed as untree-shakeable (packages/spec/src/index.ts), so the first
line of every scaffold did not resolve. They now import from the subpath and
author through the domain's validating factory (ObjectSchema.create,
defineView, defineFlow, defineAgent, defineStack) — which parses at authoring
time and, as a value import, fails loudly instead of degrading to `any`
(issue objectstack-ai#2035's rationale, applied to the scaffolds themselves).

The recurrence is the actual fix. os-view-grid broke because objectstack-ai#4001 closed
ListViewSchema and no gate anywhere could see a snippet body; the next
strictness batch would have broken another one identically. The package now
has a `test` script that expands every snippet, evaluates it against the real
@objectstack/spec, and safeParses the authored literal with the schema the
runtime uses. Three independent failure modes — the expansion does not
evaluate, the literal does not parse, an import names a binding the spec no
longer exports — plus a negative control asserting the pre-fix shape is still
rejected, a plan table that fails when a snippet arrives ungated, and a
lockstep assertion on the engines.protocol major so that stamp cannot rot.

Tests live in their own tsconfig project (the extension is CommonJS for the
VS Code host; the gate is ESM) and are wired into `typecheck`, so they are not
hidden from `tsc --noEmit`.


Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…n a key the schema tombstoned (objectstack-ai#19059)

Fixes objectstack-ai#18304

Clause-②: no — a liveness row is a claim ABOUT the schema, not the
schema. Judged against the actual diff below and it holds: no Zod
schema, accept set or published export moves, and
`check:authorable-surface` + `check:api-surface` both report their
artifacts unchanged (see Readings). None of the 17
`packages/spec/api-surface-declarations/**` shards is in this diff.

## What was wrong

`packages/spec/liveness/agent.json`, prop `tools`:

```json
"tools": { "status": "live", "evidence": "cloud: packages/service-ai/src/agent-runtime.ts", "note": "legacy direct-tool fallback." }
```

The ledger README defines `live` as **"Has a runtime consumer"**.
`agent.tools` was removed in protocol 17 (objectstack-ai#3894) and
`packages/spec/src/ai/agent.zod.ts` declares it `retiredKey(...)`, which
types the key `never` and rejects any authored value with the upgrade
prescription. So the row certified a consumer for a key nothing can put
a value into.

## The status was derived, not chosen

Two independent derivations, both re-measurable in this checkout:

**1. From the vocabulary.** The four published statuses are `live` /
`experimental`–`planned` / `dead` / `live-elsewhere`.
- `live` — "Has a runtime consumer" — is refused: a `never`-typed key
carries no value for anyone to consume, in any realm.
- `experimental` / `planned` — "Declared, intentionally not enforced
yet" — is refused: the key is removed, not pending.
- `live-elsewhere` — "Dead here by measurement, **genuinely enforced in
a sibling repo**" — is refused rather than left undeclared: it requires
a real foreign enforcer, and a key nothing can carry a value for has
nothing to enforce.
- `dead` — "Parsed, no consumer **anywhere the census looked**. Tracked
for **enforce-or-remove** (ADR-0049)" — is the remaining reading, and it
is the one that puts the key on the worklist it should have joined at
protocol 17.

**2. From the ledger's own data.** Over the 36 governed types the gate
walks, **40** top-level keys carry a `[REMOVED]` tombstone description
and **all 40 are `never`-typed**. **39 of them already said `dead`.**
`agent.tools` was the single outlier. The nearest precedents are
in-class and in-type: `field.conditionalRequired` (objectstack-ai#13043, `live` ->
`dead`, row kept) and `agent.knowledge` in this very ledger.

The row **stays** rather than being deleted, which the README fixes as
the rule for this route: "a `retiredKey()` tombstone **keeps** the key
in the walked shape, so a tombstoned key's row must **stay**, while a
strict-removed key's row must **go**."

## Which direction this moves the ledger

`live` -> `dead`, and **no key is added or removed**: the classified
total stays at 1094, the accept set is byte-identical, and the type's
own count moves `live 21 -> 20 / dead 1 -> 2`. Moving a verdict from
`live` to `dead` makes the ledger **stricter** — it adds a row to the
enforce-or-remove worklist and removes a false certification. No floor
is lowered, no ratchet entry raised, no row deleted, no test skipped or
quarantined.

## The cross-repo half is attributed, not re-asserted

Cloud is not reachable from an open-source checkout, and this seat did
not reach it. The zero-consumer census in cloud `@cb8ee7ff` and its lit
positive control (`agent.skills`, four real reads in the same tree) were
taken by the objectstack-ai#13272 re-verification on **2026-09-15**; that is the date
`verifiedAt` carries and the row says whose reading it is. What replaces
it as **load-bearing** is local and re-measurable: the `retiredKey`
tombstone covers authored input, and the ADR-0087 conversion
`agent-tools-to-skills` covers stored rows and built artifacts (the
chain is replayed with retired entries included at every rehydration
seam). Those two together mean no value reaches any consumer in any repo
— a stronger claim than the cloud census, and one CI can hold.

## The second half the card raises: this row was EXEMPT from scrutiny

`scanEvidence` (`packages/spec/scripts/liveness/evidence.mts`)
classifies a path-shaped token foreign when a realm marker is in force
**or** when it starts with one of `FOREIGN_PATH_PREFIXES`, and a foreign
path is counted and never resolved. This row's citation was foreign in
**both** of its spellings: `packages/services/service-ai/...` matched
the prefix until objectstack-ai#13309, and the explicit `cloud` marker that replaced
it is attributed the same way. **What it implies:** the repoint in
objectstack-ai#13309 changed the row's exemption from implicit to declared, and
changed nothing about its falsifiability — no gate could fail on it
before or after, in either direction. Only a human, or a seat with cloud
access, ever could. The stale `evidence` string is therefore deleted
rather than repointed; a `dead` row's pointer lives in its `note` by the
gate's own design (`EVIDENCE_UNSCANNED_STATUSES`).

Sibling rows hidden the same way are measured in Acceptance notes. ⛔
Nothing about `FOREIGN_PATH_PREFIXES` is changed here — objectstack-ai#13272's triage
ruled that out and the ruling stands.

## Diff

| Path | Why |
|---|---|
| `packages/spec/liveness/agent.json` | the `tools` row: `live` ->
`dead`, stale `evidence` deleted, `verifiedAt` + retirement story in
`note`. Plus the ledger's own `_note`, whose sentence "the row keeps its
`status` ... the liveness re-grade is triage's to make" **this change
falsifies** — it now records the landed re-grade. Declared file-surface
increment over the claim, which named the row alone. |
| `packages/spec/liveness/state-counts.md` | regenerated
(`gen:liveness-counts`); never hand-patched |
| `packages/spec/liveness/README.md` | one clause in the hand-written
`agent` Notes cell — the same half the `field.conditionalRequired`
precedent (objectstack-ai#13043) updated for its own re-grade |
| `packages/spec/scripts/liveness/tombstoned-row-status.test.ts` | new
contract test against the real ledgers (below) |
| `.changeset/agent-tools-liveness-row-dead.md` | `patch` — measured
below, `skip-changeset` is wrong |

## The test, and proof it can fail

A `[REMOVED]` tombstone's ledger row says `dead` — a class pin, not a
datum echo, on a population of 40 with a non-vacuity floor. Nothing
could catch the original defect: the forward pass and the orphan pass
are both satisfied by a tombstone (it keeps the key in the walked
shape), the evidence checks never ran on an attributed pointer, and
`check-liveness.mts` reads `[experimental` / `[planned` markers with
**no reading of `[REMOVED]` at all**.

Reverse verification, twice, from the committed state, through
`scripts/ablation-replace.mjs` so the mutation is proven on disk and the
restore is proven against `HEAD`:

| Leg | Mutation (on-disk proof) | Observed |
|---|---|---|
| 1 | `agent.tools` `dead` -> `live`; anchor 1 -> 0, replacement 10 ->
11, blob `77d2aa900129` -> `b6b8c9bf0cb0` | **RED**, 2 of 3 tests,
naming `agent.tools → status=live` |
| 2 | `agent.knowledge` `dead` -> `live`; anchor 1 -> 0, replacement 0
-> 1, blob `77d2aa900129` -> `024d4df25361` | **RED**, 1 of 3, naming
`agent.knowledge → status=live` — so the pin is a CLASS pin; the
`agent.tools` case stayed green |

Both legs restored with `blob == HEAD (77d2aa9)` and `git diff
HEAD` empty. Direction predicted before running: turns red. Direction
observed: turns red. No dist mediates either leg — the test imports
`src/` and reads the ledger JSON from disk — so no `dist` preflight
applies, and the on-disk proof is the whole chain. No permanent ablation
artefact is left behind.

## Readings — instrument, unit, control

All taken in this worktree at `447f4f21b`, cut from `origin/main` at
`abb01f105`.

| Reading | Instrument | Value | Lit control |
|---|---|---|---|
| tombstoned keys whose row is not `dead` | walk the 36 governed types
for a `[REMOVED]` description, join the ledger status | **1** before
(`agent.tools`), **0** after | 40 tombstones found, all `never`-typed,
39 already `dead` — the detector fires |
| `FOREIGN_PATH_PREFIXES` still matched by any evidence field |
`scanEvidence` over all 39 ledgers | **0** fields | **26** fields cite
the real `packages/service-ai/` path |
| scanned-status rows whose evidence resolves to zero local paths (no
gate can fail on them) | `scanEvidence`, 39 ledgers, 481 rows | **61**
rows | **254** rows carry at least one local path the gate CAN fail on |
| unresolvable foreign pointers in the gate's own census |
`check:liveness` summary line | **212 -> 211**; local resolvable **583
-> 583** | the local count is the control: this change adds no pointer
CI must resolve |
| `verifiedAt` coverage | `check:liveness` summary line | dated **628 ->
629**, undated **235 -> 234**, 0 older than 180d | — |
| does the ledger actually publish? | `npm pack --dry-run
--ignore-scripts --json` in `packages/spec` (`liveness` is in `files[]`)
| `liveness/agent.json`, `liveness/state-counts.md`,
`liveness/README.md` all **shipped** (292 files) | the new test file is
**not** shipped, and **no** `scripts/` path is — the packer
discriminates |

Changeset judgement: published content moves ⇒ a changeset is owed and
`skip-changeset` would be wrong. `patch`, on the `7cd587413` precedent
(a one-line `liveness/field.json` note repoint took a `patch`).

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (65 commands, derived from the merge base
rather than a hand-written diff).

- **62 of the 65 green**, exit code captured before any pipe in every
case.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**, including `check:authorable-surface`,
`check:api-surface` and `check:liveness` (the counts file current). This
is the mechanical half of the `Clause-②: no` judgement.
- `pnpm --filter @objectstack/spec typecheck` green. The new test file
is compiled by `tsconfig.scripts.json` — measured with `tsc --listFiles`
(1 hit there, 0 in `tsconfig.json` and `tsconfig.test.json`), so
`check:scripts-typecheck` really covers it.
- `pnpm --filter @objectstack/spec exec vitest run --project local` —
**492 files, 14298 tests, 0 failures**.
- **NOT MEASURED (3), each exiting 3 = `PREREQUISITE NOT MET`, declared
to CI rather than read as green:** `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`. All three read
built output across the whole workspace and this diff touches no package
source; a whole-repo build is CI's.
- Repo-wide `pnpm lint` is CI's run, not narrowed here.

## Acceptance notes

Out of scope, noted rather than filed from here:

1. **The liveness gate has no reading of `[REMOVED]`.**
`check-liveness.mts`'s `markerStatus()` knows `[experimental` and
`[planned` only, so a tombstoned key may carry any status and no gate
objects — the mechanism that let this row sit `live` for three months.
The class invariant is pinned by a test in this PR, but the gate itself
still cannot see a tombstone. A gate leg would start green at 40/40
after this lands. Dedupe words: `liveness tombstone status`, `retiredKey
ledger dead`, `markerStatus REMOVED`.
2. **61 rows are unfalsifiable by construction** (measured above,
control 254). The card names this asymmetry for `agent.tools`; the
population is the general case, and `agent.tools` was its sharpest
instance because the outlier was also tombstoned locally. After this
change **no** tombstoned row carries a non-`dead` status, so the
sharpest class is empty — the remaining 60 are ordinary cross-repo
claims resting on dated readings. ⛔ Not fixed here.
3. `FOREIGN_PATH_PREFIXES` matches zero evidence fields across all 39
ledgers (control: 26 cite the real path), i.e. the constant is inert.
Already recorded on the card, which rules out touching it.
4. **Next step is a separate card:** with the row `dead`, `agent.tools`
is an ADR-0049 enforce-or-remove input (playbook
`.claude/skills/spec-property-retirement`). Triage was explicit that the
retirement is not this card's.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ptions state each decision in words instead of a tracker number (stage 4) (objectstack-ai#21555)

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

Stage 4 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): class (b) of the stage-3
census, the text `packages/spec/src` shows authors and administrators.
Every rewritten string now states in words what the cited decision was,
or drops a citation its sentence already explained. Text only.

## What changed

- **56 ADR-0087 conversion summaries** in
`packages/spec/src/conversions/registry.ts` (68 tracker ids): every
protocol 16 → 17 summary that carried an id. A summary is the "Change"
column of `docs/protocol-upgrade-guide.md`, the `to` text of
`spec-changes.json`'s `converted[]` records and what `os migrate meta
--json` reports under `specChanges`, so it is read by an author
upgrading metadata.
- **Three descriptions** (3 ids): `FieldSchema.autonumberFormat`'s
`.meta()` description (`data/field.zod.ts`), and the route descriptions
of `GET /:type/:name/layers` and `POST /:type/:name/publish`
(`api/plugin-rest-api.zod.ts`).
- **Generated, by `check:generated --fix`** (exactly the three artifacts
it proved stale): `docs/protocol-upgrade-guide.md` (56 rows),
`packages/spec/spec-changes.json` (56 summaries, twice each: the
per-major record and the aggregate), and the `autonumberFormat` rows of
`content/docs/references/data/field.mdx`, `data/object.mdx` and
`system/migration.mdx`.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no`.

## Size: the split (A2)

At this base the class (b) population is unchanged from the stage-3
census: 91 conversion summaries with 108 ids (81 distinct cards) plus 3
descriptions with 3 ids. That is over the ~60-card bar, so this PR
delivers up to a protocol-step boundary, lowest step first:

| protocol step | summaries with ids | ids | distinct cards |
|---|--:|--:|--:|
| toMajor 11 / 13 / 14 / 15 | 0 | 0 | 0 |
| **toMajor 17 (delivered)** | **56** | **68** | **48** |
| toMajor 18 (next stage) | 35 | 40 | 34 (33 not cited in 17) |

The three descriptions ride this part (3 more cards, no overlap): 59
messages, 71 ids, 51 distinct cards delivered. The next stage's exact
list is the 35 toMajor-18 summaries at the end of this body.

## Delivered: each site, the decision read, the new words

Every cited card was read through REST with all comments; the record
column names the comment (or commit) the decision was read from. Where a
summary already said why, the citation is dropped and the sentence kept.
Placeholders `OBJECT_NAME` / `RULE_NAME` below stand for the
angle-bracket spelling in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `action-execute-to-target` (:727) | objectstack-ai#3713 | `execute` is the
deprecated alias of `target`; spec and objectui resolved the pair in
opposite directions; align on the spec rule and drop the alias so the
divergence is unrepresentable (body (closed completed, no comments)) |
action key 'execute' → 'target' (the deprecated handler alias; the spec
and the renderer had resolved the pair in opposite directions, so one
key now names the handler) |
| `field-conditionalRequired-to-requiredWhen` (:767) | objectstack-ai#3754 | same
fold-and-drop as objectstack-ai#3713: `requiredWhen` canonical, alias folded and
dropped from parsed output (body (closed completed, no comments)) |
field key 'conditionalRequired' → 'requiredWhen' (the deprecated
predicate alias, folded into the canonical key so no reader picks its
own precedence) |
| `agent-tools-to-skills` (:822) | objectstack-ai#3894 | ADR-0109 accepted;
`agent.tools[]` removed because it resolved names against the full
registry with no surface check, breaking ADR-0064 (tool set = union of
skills' tools) (PR body (merged)) | agent key 'tools' removed — declare
capability in a skill (ADR-0064: an agent's tools are exactly its
skills' tools, and this inline slot resolved names against the whole
registry with no surface check) |
| `sharing-rule-access-level-full-to-edit` (:881) | objectstack-ai#3865 | route B is
the end state: sharing grants read/edit only; delete, transfer and
re-share come from object permissions, ownership and admin scope; `full`
→ `edit` is lossless (5105498900) | sharing-rule accessLevel 'full' →
'edit' (`full` never granted more than `edit`; a sharing rule grants
read or edit, while delete and transfer come from object permissions and
ownership) |
| `flow-node-crud-object-alias` (:954) | objectstack-ai#3796 | the seven aliases (six
open-coded `??` + the shim's last `object`) graduate straight into the
D2 layer; the `readAliasedConfig` shim is deleted (5125152179) | CRUD
flow-node config key 'object' → 'objectName' (the last alias in the
executors' `readAliasedConfig` shim graduates into this layer, and the
shim is deleted) |
| `flow-node-notify-config-aliases` (:1077) | objectstack-ai#3796, objectstack-ai#4045 | objectstack-ai#3796: `??`
fallbacks graduate, `actionUrl` canonical (downstream chain uses it).
objectstack-ai#4045: `notify.source` was a read-but-undeclared shape → conversion
layer, not configSchema (5125152179; 5127636357, 5138487536) | notify
flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' →
'message', 'url' → 'actionUrl' (executor `??` fallbacks graduated into
this layer; `actionUrl` is canonical because the notification chain
downstream already uses it), and nested 'source: {object, id}' →
'sourceObject' / 'sourceId' (a shape the executor read that no config
schema declared) |
| `flow-node-wait-event-config-lift` (:1288) | objectstack-ai#4045 |
`node.config.eventType` etc. were an undeclared second contract beside
the declared `waitEventConfig`; graduate them (objectstack-ai#4161) (5130277099,
5138487536) | wait flow-node loose config keys → the declared
`waitEventConfig` block: 'eventType', 'timerDuration'/'duration' →
'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (the
executor also read these keys from the loose config, a second contract
beside the declared block) |
| `flow-node-map-flow-alias` (:1364) | objectstack-ai#4045 | reconciliation found the
`map.flow` alias (undeclared executor fallback); graduated (objectstack-ai#4228)
(5138487536) | map flow-node config key 'flow' → 'flowName' (an
undeclared spelling the executor accepted through a bare fallback; it
graduates into this layer) |
| `flow-node-subflow-flow-alias` (:1421) | objectstack-ai#4278 | reconcile the
schemaless nodes' forms with their executors and check `subflow`; its
bare `flowName ?? flow` fallback graduates (body (closed completed, no
comments) + conversion docblock) | subflow flow-node config key 'flow' →
'flowName' (an undeclared spelling the executor accepted through a bare
fallback, found when the schemaless nodes were reconciled with their
executors; it graduates into this layer) |
| `flow-node-connector-config-lift` (:1529) | objectstack-ai#4045 | executor reads
only `connectorConfig`; the descriptor schema rooted the triple at
`config`, so the Studio form wrote unread keys; descriptor stops
publishing, stored loose keys lift (5132926517, 5138487536) |
connector_action flow-node loose config keys 'connectorId' / 'actionId'
/ 'input' → the declared `connectorConfig` block (the executor reads
only that block; the published designer form had been writing these keys
where nothing read them) |
| `flow-node-script-config-aliases` (:1641) | objectstack-ai#3796 | as above:
open-coded `??` fallbacks graduate into the D2 layer (5125152179) |
script flow-node config keys 'functionName' → 'function', 'input' →
'inputs' (executor `??` fallbacks, graduated into this layer) |
| `app-dead-authoring-keys-removed` (:1742) | objectstack-ai#4001, objectstack-ai#4509, objectstack-ai#4667, objectstack-ai#4709
| liveness audits: keys unread or wrongly encoded are removed; objectstack-ai#4709:
the "no shell read homePageId" premise was false, ruling B keeps the
retirement (an ID cross-reference that dangles is the wrong encoding)
(5158421901 (objectstack-ai#4509), 5159774792 (objectstack-ai#4667), 5164227920 (objectstack-ai#4709 ruling B)) |
app keys
'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId'
plus contextSelectors 'includeAll'/'placement' and areas 'order' removed
(liveness audits found each one unread or wrongly encoded; sharing/embed
declared a public surface no route enforced, mobileNavigation was fully
unimplemented, includeAll was deliberately disobeyed because an 'All'
row would clear a mandatory scope, homePageId WAS read by objectui's
console before v17 but encoded the landing page as an ID cross-reference
that silently fell back when it dangled — the landing page is the first
nav item (the first retirement record said nothing read it, a premise
since corrected; the retirement stands), and no renderer ever sorted
areas) |
| `app-area-fail-open-gates-removed` (:1853) | objectstack-ai#4651 | ruling B: remove
both keys; removing a fail-open gate is strictly safer than keeping it;
prescription names the two enforced layers (5160072589) |
navigation-area keys 'visible'/'requiredPermissions' removed (ADR-0049 —
FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or
permission-gated area was served and rendered to every user, while the
identically named keys on a navigation ITEM and on the APP are enforced;
gate the items inside the area, or gate the app) |
| `permission-rls-priority-removed` (:1955) | objectstack-ai#3896 | the objectstack-ai#3896
security-audit line: rls.priority promised conflict resolution that
cannot exist (policies OR-combine) and had no reader; removed (card body
+ commits d6bfb3d (objectstack-ai#3990), eb95d97 (objectstack-ai#3998)) | RLS-policy key
'priority' removed (a security audit found no reader: policies
OR-combine, so the promised conflict-resolution semantics cannot exist;
dropping it changes no outcome) |
| `tool-inert-authoring-keys-removed` (:2024) | objectstack-ai#3896 | the audit
close-out removes the four inert tool keys (permissions gated nothing,
active:false withdrew nothing) (commit eb95d97 (objectstack-ai#3998)) | tool keys
'category'/'permissions'/'active'/'builtIn' removed (authorable and
inert, so removed under ADR-0049 enforce-or-remove; permissions gated
nothing, active:false withdrew nothing) |
| `action-inert-keys-removed` (:2137) | objectstack-ai#3896 | close-out sweep:
enforce-or-remove worklist, fourteen inert authoring keys leave the
surface (commit 12a19a8 (objectstack-ai#4054)) | action keys
'shortcut'/'bulkEnabled' removed (inert, removed under ADR-0049
enforce-or-remove: no keydown path dispatches shortcuts; the
multi-select toolbar reads the view's bulkActions) |
| `flow-inert-keys-removed` (:2162) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | flow keys 'active'/'template', node 'outputSchema'
and errorHandling 'fallbackNodeId' removed (inert, removed under
ADR-0049 enforce-or-remove: active:false never stopped a flow; status is
the enforced lifecycle) |
| `view-inert-keys-removed` (:2221) | objectstack-ai#3896 | as above (commit
12a19a8 (objectstack-ai#4054)) | view keys removed as inert (ADR-0049
enforce-or-remove): list 'responsive'/'performance', form
'defaultSort'/'aria' — no renderer read them (list aria/data and form
data stay live) |
| `view-list-passthrough-keys-removed` (:2267) | objectstack-ai#7176 | maintainer
ruling: retire under ADR-0049; pass-through-only reads are dead in
effect (5236139723) | view list keys removed:
'striped'/'bordered'/'virtualScroll' — every measured reader copied the
key forward and none applied it (a key that is only passed through is
dead in effect; ADR-0049 enforce-or-remove) |
| `view-export-options-pdf-removed` (:2335) | objectstack-ai#8010, objectstack-ai#1301 | option A;
`pdf` leaves the enum (honest narrowing, not a runtime console.warn);
PDF export declined (5270998514; objectstack-ai#1301 is not planned) | list-view
export format 'pdf' removed (PDF export was declined as not planned, and
ObjectGrid dropped the declared format from the menu with only a runtime
console.warn; an honest enum replaces that warning) |
| `dashboard-inert-keys-removed` (:2404) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | dashboard keys 'aria'/'performance'
and widget 'performance' removed (inert, removed under ADR-0049
enforce-or-remove: no renderer applied any of them) |
| `dashboard-widget-responsive-removed` (:2474) | objectstack-ai#4876, objectstack-ai#11027 | objectstack-ai#4876
ruling A: retire widget `responsive` (no reader); objectstack-ai#11027 ruling B:
retire `page.components[].responsive`, equally unread (5169512655;
5380752244) | dashboard widget key 'responsive' removed (no renderer
ever applied per-widget breakpoint overrides; the
page.components[].responsive key this entry once deferred to was
measured equally unread and retired at protocol 18) |
| `dashboard-widget-action-aria-removed` (:2555) | objectstack-ai#5010 | retire the
four dead widget keys; colorVariant kept (body ruling + 5179556002) |
dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria'
removed (no renderer ever drew a per-widget action button, and widget
ARIA attributes never reached the DOM; use header.actions[] and the
widget title/description) |
| `dashboard-widget-compareto-converged` (:2652) | objectstack-ai#5011 | converge
`compareTo` on the implemented executor contract `{ kind, dimension? }`;
`1y` rewrites, other offsets delegated (5173559485) | dashboard widget
'compareTo' converged on the executor's { kind, dimension? } contract
(the shape the dataset executor implements; the bare strings and {
offset: '1y' } rewrite mechanically; other { offset } durations have no
faithful target and are reported, not guessed) |
| `agent-knowledge-removed` (:2726) | objectstack-ai#3896 | close-out sweep (as above)
(commit 12a19a8 (objectstack-ai#4054)) | agent key 'knowledge' removed (inert,
removed under ADR-0049 enforce-or-remove: declaring sources/indexes
never scoped retrieval; restrict at the knowledge-service level) |
| `skill-trigger-phrases-removed` (:2744) | objectstack-ai#3896 | close-out sweep (as
above) (commit 12a19a8 (objectstack-ai#4054)) | skill key 'triggerPhrases' removed
(inert, removed under ADR-0049 enforce-or-remove: activation is
triggerConditions + the agent's skills[] allowlist; phrases were a
dead-end projection) |
| `stack-api-require-auth-removed` (:2782) | objectstack-ai#3963 | delete the
`api.requireAuth` opt-out; anonymous always denied; public surfaces
derive authorization from a declaration (form, share link, book
audience) (body (decision recorded in body)) | stack key
'api.requireAuth' removed — anonymous access is always denied; publish
public surfaces by declaration (a public form, a share link or
`book.audience: 'public'`), which replaced the deployment-wide opt-out |
| `flow-node-wait-timeout-keys-removed` (:2864) | objectstack-ai#4158 | wait never had
a timeout: withdraw the contract (route B), `timeoutMs` moves to
`timerDuration` (body (closed completed) + conversion docblock) |
waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its
only reader used it as the duration) and 'onTimeout' (removed — zero
readers, so no timeout ever fired): wait never had a timeout, so its
timeout contract is withdrawn rather than built |
| `datasource-inert-blocks-removed` (:2944) | objectstack-ai#4583 | all 20 dead
datasource keys removed; each job already has a different live mechanism
(5157934690) | datasource keys 'retryPolicy'/'healthCheck' and external
'label'/'requirePermission' removed (nothing retried, nothing probed on
a schedule, and the federation label/permission were read by nobody;
each of those jobs already has a live mechanism) |
| `mapping-inert-keys-removed` (:3032) | objectstack-ai#4509 | the three mapping keys
retire (schema defaults made authorWarn impossible; removal is the only
signal) (5158421901, 5158744185) | mapping keys
'extractQuery'/'errorPolicy'/'batchSize' removed (no exporter reads a
mapping, error handling belongs to the import request, and the write
path sizes its own batches) |
| `book-translations-removed` (:3087) | objectstack-ai#4667 | six dead authorWarn keys
retired (5159774792) | book keys 'translations' (book-level and
group-level) removed (no resolver read them; the tree endpoint and
portal render labels verbatim, so a localized book served its authoring
locale to everyone). Localize the docs instead: `doc.translations` is
live |
| `job-id-removed` (:3149) | objectstack-ai#4667 | as above (5159774792) | job key
'id' removed (nothing read it; `name` is the job's identity everywhere,
so two jobs differing only in `id` were the same job, and the key's own
description advertised an override that did not exist) |
| `translation-validation-messages-removed` (:3206) | objectstack-ai#4667, objectstack-ai#3778,
objectstack-ai#14381 | objectstack-ai#4667 retire; objectstack-ai#3778 legacy-key table had pointed `errors` at
it; objectstack-ai#14381 an object-scoped key ships with its reader (ADR-0049
enforced) (5159774792; objectstack-ai#3778 body; 5503980929) | translation key
'validationMessages' removed (no resolver read it, so a translated rule
message was stored and never shown; the legacy-key table of the
translation-bundle migration had been steering retired `errors:` authors
into it). Author the message on the rule itself
(`object.validations[].message`), and translate it under the
object-scoped group
`objects.OBJECT_NAME._validations.RULE_NAME.message`, which the write
path resolves (17.3.0, a translation key shipped together with its
reader) |
| `datasource-capabilities-removed` (:3264) | objectstack-ai#4583 | as above
(5157934690) | datasource key 'capabilities' removed (eleven flags no
code read; pushdown comes from the driver's own supports.*, and
`readOnly` never made anything read-only) |
| `datasource-read-replicas-removed` (:3319) | objectstack-ai#4468 | remove:
read-replica routing is an unbuilt feature (5150771330) | datasource key
'readReplicas' removed (no driver opened a replica connection and no
query path splits reads from writes; front replicas behind one endpoint
and point `config` at it) |
| `datasource-config-driver-key-aliases` (:3419) | objectstack-ai#4456 | the factory's
undeclared `??` fallbacks graduate to a D2 entry and are deleted from
the reader (5157922838) | datasource config keys → canonical per driver:
sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString'
→ 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' →
'username' (undeclared driver-factory `??` fallbacks, graduated into
this layer and deleted from the reader) |
| `flow-node-script-branch-keys-removed` (:3665) | objectstack-ai#4343 | operator
ruling: `script` converges to a pure function-call node; the five branch
keys retire (5151704360) | script flow-node config keys 'actionType' (→
'function' when it was shorthand for one; otherwise removed —
'email'/'slack' were logger-backed stubs that delivered nothing), plus
'template' / 'recipients' / 'variables' (fed those stubs) and 'script'
(inline JS the runtime never executed); script is now a pure
function-call node, the only path that ran real logic |
| `object-managed-by-system-to-system-data` (:3762) | objectstack-ai#3355 | retire
`system`, new value `system-data` (not `platform-data`) (5157022965) |
object managedBy 'system' → 'system-data' (ADR-0103's residual bucket
named the engine-owned half v16 had already moved out to `engine-owned`;
the rename leaves the name describing what the bucket actually holds:
admin/user-writable platform data) |
| `object-enable-trash-mru-removed` (:3829) | objectstack-ai#3207, objectstack-ai#2377 | remove
`enable.trash` / `enable.mru`; soft delete stays parked; last slice of
the dead-property removals (5156966571, 5161298967; 5051634768) | object
capability flags 'enable.trash'/'enable.mru' removed (the last slice of
the dead author-facing property removals: no recycle bin and no MRU
tracking ever ran; both default-true flags gated nothing) |
| `object-index-type-partial-removed` (:3912) | objectstack-ai#5248, objectstack-ai#4943 | remove
both index keys; no DDL consumer; return enforce-first on real demand
(5199336983; 5194762679 (duplicate)) | object index keys
'indexes[].type'/'indexes[].partial' removed (no driver ever read
either: the index method is the dialect's choice and a partial index is
built by a database-layer migration, not declared) |
| `retry-policy-converged` (:4070) | objectstack-ai#4661, objectstack-ai#4964 | one RetryPolicy
declaration, `backoffMs`, merged default 0 / 1 with pre-17 job defaults
written out; flow.errorHandling joins, default 0 (silent retry can
double-write) (5158710540 (analysis); 5173148383) | retry policy unified
across job.retryPolicy, try_catch retry and flow.errorHandling: base
delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults
(maxRetries 3, backoffMultiplier 2) written out explicitly now that the
merged default is 0 / 1: two declarations that differed only by accident
became one, and retry is opt-in because a retry replays whatever the
attempt already did |
| `hook-body-crypto-hash-removed` (:4249) | objectstack-ai#4391 | remove the
capability token and its build-time inference (5156969500) | script-body
capability token 'crypto.hash' removed (the sandbox never installed
ctx.crypto.hash, so the token granted a call that always threw; the CLI
inferred it too) |
| `dataset-measure-array-string-agg-removed` (:4419) | objectstack-ai#6188 | retire
`array_agg` / `string_agg`; keep and enforce `count_distinct`
(5219849918) | dataset measure aggregates 'array_agg' / 'string_agg'
removed (no SQL backend compiled them and the v1 dataset runtime refused
them by name, so a measure declaring one never produced a value; the
measure is dropped, and with it any derived measure left referencing it)
|
| `connector-rate-limit-config-removed` (:4544) | objectstack-ai#4911 | outbound
rate-limit vocabulary removed: no engine exists (implementation-first)
(body ruling + 5169405647) | connector key 'rateLimitConfig' removed (no
outbound rate-limiting engine exists; the runtime's only token bucket
limits INBOUND requests, so every knob here was inert while reading like
a configured cap. The whole ConnectorRateLimitConfig shape went with it)
|
| `field-mapping-transform-removed` (:4669) | objectstack-ai#5552, objectstack-ai#3278 |
enforce-or-remove: all five members dead, the union retires; `js`
dialect was retired as redundant with the L2 script body (5199338349;
objectstack-ai#3278 body) | field-mapping key 'transform' removed (the whole
five-member FieldMappingTransform union went with it: no runtime ever
executed constant/cast/lookup/javascript/map, and the javascript member
advertised dialect="js", a dialect already retired because JavaScript
belongs in a script body. The enforced transform pipeline is the import
mapping's string-enum `mapping.fieldMapping[].transform`, which is
unaffected) |
| `theme-inert-token-scales-removed` (:4786) | objectstack-ai#5021 | retire all nine
token groups; re-declare under `customVars` (5175091297) | theme keys
'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing',
'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed
(ADR-0049 — the engine emitted --font-size-*, --font-weight-*,
--line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*,
--font-heading and --font-mono faithfully, and no first-party component
or stylesheet has ever read one. Re-declare any variable you actually
consume under customVars, which emits it verbatim) |
| `page-header-subtitle-alias` (:4940) | objectstack-ai#3226 | route B: a D2
conversion rewrites `description` → `subtitle`; the consumer's bare `??`
retires (5160118898, 5194297145) | page-header component prop
'description' → 'subtitle' (the off-spec spelling a renderer tolerated
through a bare `subtitle ?? description` fallback; `subtitle` is the
declared key, and the fallback retires) |
| `record-picker-display-field-to-label-field` (:5165) | objectstack-ai#5775 |
direction A: `labelField` (the delivered spelling) becomes canonical;
`displayField` retires via conversion (5202137085) | record-picker
component prop 'displayField' → 'labelField' (the required key no
renderer read; `labelField ?? 'name'` is what renders the row, so the
delivered spelling became the declared one) |
| `record-picker-inert-keys-removed` (:5287) | objectstack-ai#5775 | `searchFields` /
`multiple` retire (zero readers) (5202137085) | record-picker component
props 'searchFields'/'multiple' removed (the control is a plain
single-select with no search box; neither key had a reader) |
| `page-card-body-to-children` (:5414) | objectstack-ai#5775 | `children` is the one
composition key (5202137085) | page:card component prop 'body' →
'children' (one composition key across every container; the card
renderer already reads both) |
| `inline-action-api-params-to-body-extra` (:5582) | objectstack-ai#5777 | direction
A: the static payload gets its own key (`bodyExtra`); `params` keeps one
meaning (5202138112, 5228796853) | inline type:'api' action prop
'params' (object form) → 'bodyExtra' (a static payload and a parameter
definition are two things, so the payload gets its own key; `params`
stays the ActionParam[] definition array) |
| `page-tabs-type-to-tab-style` (:5847) | objectstack-ai#6776 | Route A: rename to the
spelling the renderer reads (5229120747, 5229693342) | page:tabs
component prop 'type' → 'tabStyle' (a props key named `type` collides
with the node's dispatch key and is unauthorable in flat/JSX carriers;
`tabStyle` is the spelling the renderer reads in all of them) |
| `page-structure-inert-keys-removed` (:6035) | objectstack-ai#6946 | retire three
zero-reader UI keys (body (maintainer ruling) + 5232767409) |
page:header prop 'icon' and page:card prop 'actions' removed (neither
has a renderer read point in objectui; the header resolves icons per
action and the card renders title/children/footer only) |
| `record-details-layout-removed` (:6201) | objectstack-ai#6946 | as above (as above)
| record:details component prop 'layout' removed (the declared
auto\|custom modes were never implemented; the renderer branches only on
inline\|compact, values the schema never permitted, so both legal values
selected nothing) |
| `app-hidden-to-unpublished` (:6343) | objectstack-ai#4829 | A1: a machine-managed
key carries the publish gate; `hidden` back to navigation presentation
only; ADR-0045 amended (5173161521) | stored app publish gate 'hidden' →
'_unpublished' (ADR-0045 amended — `hidden` carried BOTH the publish
gate and 'keep out of the App Switcher', so the built-in Account app was
withheld from every non-builder; the gate is now the machine-managed
`_unpublished`, and `hidden` is navigation presentation only, never an
access gate. Stored rows only — an authored `hidden: true` is left
untouched) |
| `action-global-nav-location-removed` (:6456) | objectstack-ai#6888 | direction 2:
retire `global_nav` (no demand; the designer previewed a surface the
product lacks) (5229990375) | action location 'global_nav' removed (no
running-app surface rendered it; the ⌘K palette reads no action
metadata, while the Studio designer previewed a command-palette frame
for it. The value is stripped and the key kept, so an action left with
no location becomes the documented headless shape `locations: []`) |

Descriptions:

| site | cited | decision as read (record) | change |
|---|---|---|---|
| `data/field.zod.ts` `autonumberFormat` | objectstack-ai#6555 | route 3: `{0000}` is
a declared contract default, and both hand-written fallbacks read it
(5225535766, family done 5240072006) | "⇒ the contract default `{0000}`,
which every driver and the engine fallback read, so one field numbers
alike on every backend." |
| `api/plugin-rest-api.zod.ts` `GET /:type/:name/layers` | objectstack-ai#5882 |
ruling B: its own `/layers` path and schema, one route one shape
(recorded 5216370790) | "…hence its own path and its own response
schema, since one route answers one shape." |
| `api/plugin-rest-api.zod.ts` `POST /:type/:name/publish` | objectstack-ai#7294 |
declare the served publish route's response, the save door's discipline
(5237410722) | "The route was served for a long time with no declaration
behind it — this entry is what makes its response contract nameable, the
same declared-equals-returned rule the save door follows." |

## Text-only proof (A4)

Stage 3's AST-skeleton + string-text tool (`skeleton.cjs`, one line
changed: the TypeScript 6.0.3 load path), BASE `1ac7308d7a` against this
branch: **3 of 3 SAME** on both legs, exit 0 each — `registry.ts` 62346
tokens, 4920 string groups, 56 changed; `field.zod.ts` 8682 / 581 / 1;
`plugin-rest-api.zod.ts` 4912 / 475 / 2 (one literal re-split into three
`+` pieces, which the skeleton reads as one string); parse diagnostics 0
/ 0. Every changed group carried an id before and carries none after;
every other string is byte-identical. Controls on scratch copies of the
head `registry.ts`, each mutation counted on disk first: an identifier
rename → DIFF exit 1; `!==` flipped to `===` → DIFF exit 1; one summary
re-split into two `+` operands → SAME exit 0; a `surface` string (never
carried an id) changed → text leg VIOLATION exit 1. No repo file was
mutated for the controls.

Census after the edit (stage 3's instrument, unchanged): non-test 219 →
160 messages, 429 → 358 ids; class (b) conversion summaries 91 / 108 →
35 / 40 (all toMajor 18); descriptions 3 / 3 → 0.

## Pins and quotes (A6)

- **Tests:** no test asserts a changed summary or description phrase.
The id-bearing fragments and the distinctive phrases of all 59 messages
were searched across every `*.test.*` / `*.spec.*`; the hits are other
files' own prose with their own citations (test titles and comments such
as `(objectstack-ai#3896 close-out)` in `view.test.ts`), not quotes of a summary.
- **`content/docs/**`:** the only quotes are the three generated
`references/**` pages, regenerated.
`content/docs/releases/v17/17-0.mdx:1052` repeats one action-key phrase
with its own `(objectstack-ai#3896 close-out)`; it is release-owned and untouched.
- **`skills/**`:** no quote of any changed text.

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `db81973cf6` (9 paths, 493 changed lines): 105 commands,
each run from the worktree with its exit code written before any pipe;
`--ran` → "✓ dispatch-gates --ran: 105 derived famil(ies) accounted for
— 105 run, 0 NOT-MEASURED". Three first exited 3 (prerequisite: unbuilt
lint / client packages) and were re-run green after the full package
build (71 tasks). Plus `@objectstack/spec` build, `check:generated`
("All 15 generated artifacts are up to date"), the package `test`
project (603 files, 17845 passed), 48 of the 51 `test:repo` files, and
`typecheck`. Details are in the report on the card.

## Acceptance notes

- **The `objectstack-ai#3896` citations.** Eight summaries cited `objectstack-ai#3896` as an "audit"
or a "close-out". The card's own body is the sharing-rule REST finding
that opened that security-audit line; the decisions the summaries cited
(remove `rls.priority`, the four inert tool keys, then the fourteen-key
enforce-or-remove sweep) are recorded in the landed commits
`d6bfb3d0ab`, `eb95d97c02` and `12a19a88a5`. Each summary already said
why its key went, so the new words name the rule (ADR-0049
enforce-or-remove) rather than the card.
- Comments in `conversions/registry.ts` still carry tracker ids; they
belong to objectstack-ai#20234's comment stages and are untouched. So is
`migrations/registry.ts`.
- `docs/protocol-upgrade-guide.md` is not a governed surface
(`check-governed-merges`' register).
- **Hot file:** open PR objectstack-ai#21547 (objectstack-ai#21459) also edits
`conversions/registry.ts`: it adds one toMajor-18 entry and its ordering
row, in a region this PR does not touch. A local `git merge-tree` of the
two heads is clean (the file is hand-written, so no merge driver is
involved). Neither PR's spec-changes / upgrade-guide output includes the
other's entries, so whichever lands second merges `main` and regenerates
them.

## Next stage: the 35 toMajor-18 summaries (file:line at this head · ids
· conversion)

- `registry.ts:6550` objectstack-ai#8321 `field-malformed-scale-precision-removed`
- `registry.ts:6658` objectstack-ai#8762 `record-chatter-position-vocabulary`
- `registry.ts:6777` objectstack-ai#9198 `element-input-target-variable-removed`
- `registry.ts:7024` objectstack-ai#9220 `element-filter-removed`
- `registry.ts:7177` objectstack-ai#9249 `element-form-removed`
- `registry.ts:7355` objectstack-ai#15178,objectstack-ai#19620
`translation-per-app-settings-removed`
- `registry.ts:7609` objectstack-ai#9249 `translation-component-submit-label-removed`
- `registry.ts:7782` objectstack-ai#3951,objectstack-ai#9227 `field-column-lists-canonicalized`
- `registry.ts:7909` objectstack-ai#10414 `metric-filters-removed`
- `registry.ts:8104` objectstack-ai#17296 `cube-sub-day-granularities-removed`
- `registry.ts:8237` objectstack-ai#18612 `cube-join-sql-and-relationship-removed`
- `registry.ts:8502` objectstack-ai#10054 `record-highlights-field-icon-removed`
- `registry.ts:8759` objectstack-ai#11027 `page-component-responsive-removed`
- `registry.ts:8860` objectstack-ai#11805 `object-grid-default-sort-removed`
- `registry.ts:9047` objectstack-ai#21445 `object-grid-resizable-columns-removed`
- `registry.ts:9250` objectstack-ai#17260 `object-kanban-quick-add-removed`
- `registry.ts:9563` objectstack-ai#12497,objectstack-ai#1883
`permission-allow-restore-purge-removed`
- `registry.ts:9881` objectstack-ai#6837 `field-reference-to-alias`
- `registry.ts:10301` objectstack-ai#14478 `hook-timeout-to-timeout-ms`
- `registry.ts:10342` objectstack-ai#14478 `job-timeout-to-timeout-ms`
- `registry.ts:10946` objectstack-ai#14478
`api-endpoint-cache-ttl-to-cache-ttl-seconds`
- `registry.ts:11015` objectstack-ai#14478
`dashboard-refresh-interval-to-refresh-interval-seconds`
- `registry.ts:11376` objectstack-ai#14478
`memory-persistence-auto-save-interval-to-ms`
- `registry.ts:11600` objectstack-ai#14478 `turso-config-timeout-to-timeout-ms`
- `registry.ts:11690` objectstack-ai#17063 `view-page-mount-removed`
- `registry.ts:11795` objectstack-ai#17053,objectstack-ai#8221
`list-view-sort-string-clause-to-array`
- `registry.ts:12295` objectstack-ai#19054 `object-tenancy-organization-field-removed`
- `registry.ts:12405` objectstack-ai#20085 `view-item-owner-hidden-removed`
- `registry.ts:12549` objectstack-ai#20230 `view-overlay-owner-hidden-removed`
- `registry.ts:13137` objectstack-ai#6206,objectstack-ai#17321
`page-component-filter-record-to-rule-array`
- `registry.ts:13392` objectstack-ai#20161 `report-joined-chart-removed`
- `registry.ts:13657` objectstack-ai#20221 `form-layout-inline-grid-to-vertical`
- `registry.ts:13813` objectstack-ai#19992 `currency-config-precision-removed`
- `registry.ts:13917` objectstack-ai#20321 `permission-rls-tags-removed`
- `registry.ts:14056` objectstack-ai#15429 `flow-decision-mode-inclusive-explicit`

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ace, meta-spelling and studio test titles state each cited decision in words instead of a tracker number (stage 14) (objectstack-ai#21799)

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

Stage 14 of this card, and the fifth area of class (e): the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the eight small directories together: `security/`,
`ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`,
`meta-spelling/` and `studio/`. Their 91 test-title and test-string
literals carried 96 tracker ids citing 65 records. 94 ids in 89 literals
now either state what their record decided, in words (form D), or are
dropped where the title already says it. Two ids stay, for the reason
given below. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`e83c9f6154`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and
`census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`),
byte-identical to the copies stages 10 to 13 used. A literal counts as a
test title when its folded message is argument 0 of a `describe` / `it`
/ `test` call, `.each` / `.skip` / `.only` chains included. Everything
else is an "other" string.

The worktree was cut from `origin/main` at `e83c9f6154`, one commit past
the claim's `8256a4b272`. That commit touches only
`api/error-code-ledger.zod.ts`, which is not a test file, so the test
census is the same at both.

Both instruments read **1414 messages / 1500 ids in 315 files**, the
seat's reading at `8256a4b272`. That is one more than stage 13's head
reading (1413 / 1499 at `72513933ee`), and the one id is in
`ui/component-props-unknown-members.pin.test.ts`. It moved from 1 / 1 to
2 / 2 when objectstack-ai#21764 (`4331a6b16c`, 2026-10-04T17:33Z) landed between the
two readings. That commit removed one id-bearing string and added two: a
`ruling:` value at `:322` that the assertion at `:417` matches with a
regular expression on its number, and a `describe` title at `:596`. It
joins the `ui/` stages.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`security/`** (this PR) | 8 | **28 / 28** | 28 / 28 | 0 |
| **`ai/`** (this PR) | 9 | **18 / 20** | 13 / 15 | 5 / 5 |
| **`identity/`** (this PR) | 6 | **15 / 15** | 14 / 14 | 1 / 1 |
| **`integration/`** (this PR) | 4 | **14 / 14** | 13 / 13 | 1 / 1 |
| **`migrations/`** (this PR) | 2 | **9 / 12** | 9 / 12 | 0 |
| **`marketplace/`** (this PR) | 2 | **3 / 3** | 3 / 3 | 0 |
| **`meta-spelling/`** (this PR) | 1 | **2 / 2** | 2 / 2 | 0 |
| **`studio/`** (this PR) | 2 | **2 / 2** | 2 / 2 | 0 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **315** | **1414 / 1500** | **1330 / 1412** | **84 / 88**
|

The eight directories read **91 messages / 96 ids in 34 files**, the
seat's figures.

- **Controls.** Lit, a title with three ids:
`migrations/migrations.test.ts:293`. Lit, an `expect` message:
`identity/api-key-retirement.test.ts:82`. Dark: the comment at
`security/permission.test.ts:309` ("The objectstack-ai#12497 refusal shape was
measured as") reads 0. Planted in a scratch copy of the head
`security/explain.test.ts`: an id put back into a title reads 1 / 1, and
an id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in
seven of the eight directories. In `studio/` it reads 5 / 5 at the base,
because three literals in two test files are hex colours (`#7c3aed`,
`#2563eb`, `#94a3b8`). None matches the gate's pattern.
- **At the head:** 1325 messages / 1406 ids in 282 files. `ai/` reads 2
/ 2 (the two needles below), and the other seven directories read 0 / 0.
Nothing outside the eight moved. The wider pattern adds only the three
hex colours.

## How the area was chosen

Stage 10's rule ranks whole first-level directories by ids and takes the
busiest within about 10% of the ~100-id bound. `data/` (501), `ui/`
(416), `api/` (201) and `system/` (165) each exceed it alone, and the
files directly in `src/` (120) are 20% over. The eight small directories
read 96 together, within the bound. That is the group the stage-12 and
stage-13 ACCEPTs named, so the rule needed no second pass.

**Named for the next stages** (the head census, 1325 / 1406):
- `data/` 501 in five stages. It has one subdirectory, so the files
directly under it go in name order, in groups near the bound:
1. `aggregate-field-type-compatibility.test.ts` to
`default-value-tokens.test.ts`: 22 files, 109 ids;
2. `document.test.ts` to `filter-dotted-head.test.ts`: 21 files, 109
ids;
3. `filter-empty-operator.test.ts` to `hook-body.test.ts`: 21 files, 108
ids;
  4. `hook.test.ts` to `record-surface.test.ts`: 16 files, 107 ids;
5. `search-fields.test.ts` to `validation.test.ts` (8 files, 16 ids)
with `data/driver/` (7 files, 52 ids): 68 ids.
- `ui/` 416, about four stages.
- `api/` 201, two.
- `system/` 165, two.
- The files directly in `src/`, 120, one.
- The two needles left in `ai/build-progress.test.ts` and the one in
`contracts/approval-service.test.ts`. Each leaves only together with the
source docblock it pins.

## What each id became

29 literals (33 ids) now state a decision in words. 60 literals (61 ids)
drop a number the title already explains. Every cited record was read
with its comments through REST: 60 answer 200. objectstack-ai#6362, objectstack-ai#8715 and objectstack-ai#14676
answer 404, and their decisions were read from the landing commits.
`cloud#1967` and `cloud#2172` answer 403, because the cloud repository
is not attached to this session. `cloud#1967`'s decision was read from
what landed, and `cloud#2172` is one of the two needles that stay.

| record(s) | literal | now reads |
|:--|:--|:--|
| objectstack-ai#16870 | `security/explain.test.ts:413` | "the AUTHORING accept set
refuses a readScope beside viewAllRecords, the pair this snapshot shape
tolerates". The record refuses a depth axis beside the super-user bit
that short-circuits it. |
| objectstack-ai#17189 | `security/high-privilege.test.ts:30` |
"describeHighPrivilegeBits — an app-declared capability is not a
platform system permission". Ruling (i): a name on the stack's declared
capability list does not count as a system permission. |
| objectstack-ai#3391 | `security/permission.test.ts:545` |
"EffectiveObjectPermissionSchema (response side: the server-resolved
operations the UI renders)". The contract: the server resolves each
object's effective operations, and the UI only renders what it is
served. |
| objectstack-ai#6762 | `security/rls.test.ts:704` |
"RowLevelSecurityPolicySchema.using — the published description
advertises what the compiler lowers". The description was corrected to
the subset ADR-0058 widened. |
| objectstack-ai#12699 (2) | `security/tenancy-posture.test.ts:21`, `:50` |
"PlatformGlobalObjectsSchema — the objects a deployment exempts from the
Layer 0 wall" and "OrgScopingEntitlementSchema — the deployment facts
Layer 0 arming reads". |
| objectstack-ai#15813 | `security/tenant-layer0-verdict.test.ts:16` |
"TenantLayer0VerdictSchema — the four verdicts the wall records on an
operation". Ruling (i): `plugin-security` records the Layer 0 verdict it
computed, and the publish site reads it. |
| objectstack-ai#3820, objectstack-ai#3894 | `ai/agent.test.ts:74` | "agent.tools retirement
(ADR-0064) — tombstoned; tools move into skills". `agent.tools[]` was
removed, and the docs teach the action-to-skill path. |
| objectstack-ai#3278 | `ai/knowledge-source.test.ts:97` | An `it.each` row: "a
dialect the protocol does not declare (`js`, a retired expression
dialect, ADR-0058 addendum)". |
| objectstack-ai#7113 (2) | `ai/skill-trigger-condition-value-shape.test.ts:47`,
`:175` | "a set operator carrying a scalar is refused at authoring time"
and "the value-shape refinement does not disturb the carrier". The value
is shaped by its operator at authoring time. |
| objectstack-ai#3896 | `ai/skill.test.ts:195` | "retired `triggerPhrases` — phrases
never routed a skill; triggerConditions do". See the note below the
table. |
| objectstack-ai#8715 | `identity/api-key-retirement.test.ts:82` | A declared `expect`
message: "... must have zero holders after the ApiKeySchema retirement".
The record answers 404. `2c86fe3ea7` retired the fictional
`ApiKeySchema`, so `sys_api_key` has one declaration. |
| objectstack-ai#18509 (2) | `identity/identity.test.ts:88`,
`identity/organization.test.ts:132` | "UserSchema.image accept set —
null, the shape better-auth serves", and the same for
`OrganizationSchema.logo` (landed as `b9d5422142`). |
| objectstack-ai#11965 | `identity/platform-admin-capabilities.test.ts:10` |
"ADMIN_FULL_ACCESS_CAPABILITIES — the one platform-admin list
plugin-security imports". Choice 6A. |
| objectstack-ai#8681 | `identity/platform-admin-capabilities.test.ts:34` | "the
wildcard grants NO export — export stays an opt-in axis, pinned at the
declaration's new home". Direction (a): `allowExport` left the admin
sets' wildcard entry. |
| objectstack-ai#3017 | `integration/connector-provider-errors.test.ts:13` |
"connector provider upstream-unavailable classification — an unreachable
upstream degrades instead of aborting boot". Configuration faults stay
fatal. |
| objectstack-ai#4395 | `integration/connector.test.ts:244` |
"ConnectorActionSchema.effect — declares whether an action reads or
writes". The ruling: an optional read-or-write declaration the run
summary counts. |
| objectstack-ai#6362 | `integration/connector.test.ts:846` | "ADR-0010 protection
envelope — preserved, never silently stripped". The record answers 404.
The decision is read from `b5404f496f`. |
| objectstack-ai#14676 (2) | `integration/connector.test.ts:1163`, `:1195` | A
declared `expect` message, "... after the errorMapping retirement", and
"the errorMapping retirement is registered under ADR-0087". The record
answers 404. The decision is read from `13c48c2a55`, which retired the
eleven `connector.errorMapping` keys. |
| objectstack-ai#4722 | `migrations/migrations.test.ts:258` | "keeps `visible`
client-side only — the half the server-side item gate did NOT change".
The record made the server filter the nav entries inside `areas[]`. |
| objectstack-ai#4651 | `migrations/migrations.test.ts:268` | "still carries the
area-gate removal history the step exists to explain". Ruling B removed
the fail-open area keys. |
| objectstack-ai#5015, objectstack-ai#4610, objectstack-ai#5781 | `migrations/migrations.test.ts:293` |
"protocol-17 NotificationAction / EmbedConfig entry — stops republishing
the falsified zero-consumer claim". |
| objectstack-ai#4610 | `migrations/migrations.test.ts:299` | "finds the entry, and it
still explains the dual-source orphaning (anti-vacuity)". |
| objectstack-ai#5561, objectstack-ai#6844 | `migrations/migrations.test.ts:333` | "protocol-17
resumeAuthority default-flip entry — supportsPause is enforced now, so
stop asking for a hand-audit". |
| objectstack-ai#17594 | `migrations/migrations.test.ts:455` | "protocol-18
element:filter / element:form entry — the chain NAMES the bare node it
leaves standing". |
| objectstack-ai#19056 | `migrations/migrations.test.ts:555` | "every major the floor
move to 16 dropped is refused, by name". The maintainer's ruling raised
the migration support floor from 10 to 16. |

**Dropped only (61 ids):** objectstack-ai#123, objectstack-ai#3544, objectstack-ai#4001 (4), objectstack-ai#4641, objectstack-ai#4703, objectstack-ai#4737,
objectstack-ai#4911, objectstack-ai#5337, objectstack-ai#5481, objectstack-ai#5515 (4), objectstack-ai#5685, objectstack-ai#5955, objectstack-ai#6628, objectstack-ai#6698, objectstack-ai#6861,
objectstack-ai#6919, objectstack-ai#7113 (4), objectstack-ai#7319 (2), objectstack-ai#7990, objectstack-ai#8326 (6), objectstack-ai#8424, objectstack-ai#8715, objectstack-ai#9885,
objectstack-ai#11503, objectstack-ai#12497, objectstack-ai#12840 (2), objectstack-ai#14103, objectstack-ai#14676 (2), objectstack-ai#14825, objectstack-ai#15028, objectstack-ai#15680,
objectstack-ai#15813, objectstack-ai#16870, objectstack-ai#17425, objectstack-ai#17487, objectstack-ai#18728 (4), objectstack-ai#18978, objectstack-ai#20321, objectstack-ai#21260 (2),
`cloud#1967`.

- Each of these titles already states the decision it pins: for example
"the cap is 200: exactly 200 ids parse, 201 are refused (never
truncated)" for objectstack-ai#8326, or "unknown keys are rejected, not stripped" for
objectstack-ai#4001. In `skill-trigger-condition-value-shape.test.ts:130`, "(objectstack-ai#5685: no
stricter than the runtime)" became "— no stricter than the runtime".
- **`objectstack-ai#123`** in `ai/conversation.test.ts:294` (`'Support Chat - Case
objectstack-ai#123'`) is a placeholder that cites no record: objectstack#123 is an
unrelated broken-links report. The string is a fixture's session name,
an input only. No assertion reads it, so dropping the number moves
nothing.
- **`cloud#1967`** in `ai/solution-blueprint.test.ts:674`: the record
answers 403. Its decision is read from `3e3ecb0e8f` and its CHANGELOG
entry: the strict mirror the design model generates against carries the
applier's `SNAKE_CASE` constraints. The title already says "VALUE
parity".
- **`objectstack-ai#3896`** is the sharing-rule card (`POST /data/sharing/rules`
bypassing `SharingRuleSchema`). The skill title cited it as an "audit
close-out", the batch that removed `triggerPhrases` along with other
dead clusters. The title now states the decision that landed with the
key's tombstone (`ai/skill.zod.ts:386`) and its conversion entry
(`conversions/registry.ts:2744`): phrases were never matched, and
activation is `triggerConditions` intersected with the agent's
`skills[]`.
- **The `it.each` rows** at `ai/knowledge-source.test.ts:97` and `:98`
feed a `%s` placeholder. vitest 4.1.11 formats `%s` with `String(value)`
and does not truncate it (`@vitest/utils` `baseFormat`). Only `$name`
interpolation goes through the 40-character `objDisplay`. Both rows are
short anyway, and the name comparison below confirms both full names.

## The two ids that stay

`ai/build-progress.test.ts:236` and `:237` are
`expect(SOURCE).toContain('cloud#2172')` and
`expect(SOURCE).toContain('objectui#7388 block 2')`. They are not
titles. They are the expected values of assertions that read the
`ai/build-progress.zod.ts` docblock and pin that its liveness watch
names its two carriers (`:84-85`). Changing them needs a code comment
and assertion logic, which this claim excludes. They leave together with
that docblock's citations, like the
`contracts/approval-service.test.ts:274` needle.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under the
eight directories, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`ai/tool-confirmation-prescription-tense.pin.test.ts` and
`identity/position-delegatable-enforcer.pin.test.ts`. Both were run in
the `repo` project at the base and at the head, and the other 32 in
`local`.
- **By substring:** every old literal, plus a window around each id (263
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The hits are:
- **the plan's own siblings:** `unknown keys are rejected, not stripped
(objectstack-ai#4001)` in the four files this PR edits;
- **this card's later stages:** same-text titles in
`data/driver-nosql.test.ts:375`, `data/driver/memory.test.ts:548`,
`data/driver/turso.test.ts:172` and `ui/dashboard.test.ts:717` (`carries
its unit (objectstack-ai#15680)`), `data/object.test.ts:105` (`(objectstack-ai#5955)`) and
`ui/action.test.ts:1612` (`objectstack-ai#3896 close-out`). They are already in the
`data/` and `ui/` census;
- **comments and release text:** the comment at `ai/agent.test.ts:193`,
the `security/sharing.zod.ts:261` docblock, two
`packages/spec/CHANGELOG.md` entries and
`content/docs/releases/v17/17-0.mdx:326`. None reads a test title, and
none is this card's share.
- **Migration tooling and generated files:**
`docs/protocol-upgrade-guide.md`, `packages/spec/spec-changes.json`,
`packages/spec/src/migrations/registry.ts` and the 842 files under
`migrations/entries/`, together with `spec-changes.ts`, `chain.ts`,
`index.ts` and `types.ts`. Searched for every changed literal whole, at
the base and at the head (178 needles), they read 0 hits. The lit
controls `resumeAuthority`,
`ui-notification-action-embed-config-retired` and
`element-filter-and-form-node-refused` hit 5, 4 and 2 files.
`migrations.test.ts` finds each entry by its `id`, never by a title.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are
`ai/conversation.test.ts:294`, `ai/knowledge-source.test.ts:97` and
`:98`, `identity/api-key-retirement.test.ts:82` and
`integration/connector.test.ts:1163`.

- **Result:** 34 of 34 files SAME on all three legs, as predicted in
writing before the run. `ai/build-progress.test.ts` reads SAME with 0
changed.
- **Totals:** 89 changed literals, 84 titles and 5 declared. The diff's
`+` and `-` lines are exactly the 89 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 34 files were run at the base, in a separate base
worktree at `e83c9f6154`, and at the head, with `--project local
--project repo`. Both sides read 911 / 911 passed, with the same count
and status sequence per file in 34 of 34. 310 full test names change,
and each equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
34 touched files are in it, and no `*.test.ts` at all. The controls
`src/security/permission.zod.ts`, `src/ai/knowledge-source.zod.ts` and
`dist/security/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `b364b8179b`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18358 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 34 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 13, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 34 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 34 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 178 changed lines.

## Acceptance notes

- **The two `build-progress` needles** stay with the
`ai/build-progress.zod.ts` docblock they pin. The
`contracts/approval-service.test.ts:274` needle is untouched, as the
claim required.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 90 lines citing ids this PR handled, in 16
packages: `plugin-security` 28 (14 files), `rest` 20 (7),
`service-automation` 11 (7), `lint` 5 (4), `plugin-audit` 4 (3),
`runtime` 4 (2), `qa/dogfood` 3 (2), `plugin-hono-server` 3 (1),
`client` 2, `platform-objects` 2, `plugin-sharing` 2, `cli` 2,
`objectql` 1, `connectors` 1, `formula` 1 and `plugin-approvals` 1.
- **Code comments still carry ids** in these files and their sources,
for example `ai/agent.test.ts:193` and `security/sharing.zod.ts:261`.
Comments are not this card's share, and none is touched here.
- **`origin/main` moved** two commits past the base before this PR
opened (objectstack-ai#21780, objectstack-ai#21783). Neither touches `packages/spec` or any file
here, so nothing was merged. The gate reconciliation noted that two
baselines changed across them (`query-options-erasure`, `slot-lookup`).
This diff feeds neither, and the queue re-runs both on the merged
generation.

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

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.

2 participants