Repository navigation
feat(spec,lint)!: accept ADR-0109, remove agent.tools[], lint agent authoring, model AI exposure in tool resolution (#3820) - #3894
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 104 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
marked this pull request as ready for review
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>
This was referenced Jul 28, 2026
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>
This was referenced Oct 3, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-referencesresolvedaction_<name>against every declared action. The runtime is far stricter (ADR-0011): a tool materialises only when the actionai.exposed: trueandai.descriptionscript/api/flow—url/modal/formare UI-onlytarget(or abody, forscript)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[]removedADR-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[].nameagainst the full registry with no surface check, so anask-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).AIToolSchemaand theAITooltype go with it; the json-schema manifest key is deregistered in the same PR, as that gate requires. Authoringtoolsremains a silent no-op rather than a parse error, so existing stacks keep parsing. Migration: attach capability throughskills.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
defineStackstill 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. JoinsREFERENCE_INTEGRITY_RULES.5. Docs teach the default path
content/docs/ai/agents.mdxgains a worked Action → skill example with nodefineToolanywhere, 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 --noEmitclean ·check:docs,check:skill-refs,check:react-blocks,check:skill-examplesin sync ·check:api-surfacecorrectly flagged both removals as breaking before the snapshot was regenerated (changeset ismajor).@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 reachvalidate/lint/compilethrough the suite.pnpm buildgreen.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