Protocol 16 → 17 upgrade guide
The mechanical conversions and the semantic to-dos for moving metadata from protocol 16 to 17, generated from the ADR-0087 registries.
Released. Protocol 17 ships in
@objectstack/spec(this tree versions it 17.7.0).
Generated at docs build from the ADR-0087 registries on main (@objectstack/spec conversions/ + migrations/): an entry shows here once it merges, before the release that ships it. What a given release shipped is its own copy: every @objectstack/spec release after 17.7.0 carries protocol-upgrade-guide.md in the package and attached to its @objectstack/spec@VERSION GitHub Release; 17.7.0 and earlier carry none.
How to upgrade — from protocol 16 onward
objectstack migrate meta --from <your-major> # replays every hop from your major to 18, in order
objectstack migrate meta --from 16 --step # checkpoint after each major (bisect a failure)
objectstack validate && tsc --noEmit && <your tests> # your own verify loop is the acceptance testMechanical rewrites are applied for you and reported as a diff; semantic TODOs are printed with acceptance criteria and are yours to resolve — the chain never auto-applies a change that requires judgment.
The chain's support floor is protocol 16 — 2 majors behind the current protocol 18, and no earlier. A consumer further behind must reach protocol 16 by another path first (an older @objectstack/cli still carries the retired steps) before this command will run. From protocol 16 forward, replaying every remaining hop in one command is the designed-for case — that part of timeliness is never load-bearing (ADR-0087); arriving from before the floor is not supported at all.
Protocol 16 → 17
Protocol 17 removes the last three deprecated authorable aliases: action execute (use target), field conditionalRequired (use requiredWhen), and agent knowledge.topics (use knowledge.sources). Each was already lowered into its canonical key at parse time and dropped from the parsed output, so no runtime behaviour changes — only the authorable surface shrinks to one spelling per slot. All three are pure key renames with unchanged values and replay losslessly; the schemas reject the removed spellings with a fix-it error naming the replacement.
It also removes the sharing-rule access level full: declared as "Full Access (Transfer, Share, Delete)" but never enforced as anything but edit — both gates matched edit/full alike, so Setup promised admins a delete grant it never issued (ADR-0078). Unlike the OWD sharingModel: 'full' alias retired at step 13, this one HAS a lossless target precisely because it was inert — old and new shapes are behaviourally identical — so it converts mechanically and leaves no semantic residue. It is the one protocol-17 conversion that keeps a load-path acceptance window: it had no prior deprecation, and a removed enum value cannot carry the fix-it error the three key renames tombstone theirs with.
Finally it removes agent tools: the legacy inline {type,name,description}[] fallback, which the runtime resolved against the FULL tool registry with no surface check — the one seam that broke ADR-0064's "an agent reaches exactly its surface-compatible skills' tools, nothing falls through to the global registry". Unlike the renames above this has NO lossless target: each entry has to become a reference inside a skill, which is a human decision about which skill. The conversion therefore drops the dead key (the cloud runtime had stopped reading it, so it already contributes nothing) and emits a notice per agent so the author knows where capability must be re-declared; the schema tombstones the key with a fix-it error naming skills.
Beyond those spec-surface removals, it graduates the seven flow-node config key aliases the executors still tolerated: the CRUD nodes' object (use objectName) — the last tenant of the readAliasedConfig executor shim, which is deleted with it — plus the six open-coded fallbacks that never went through that shim: notify to/subject/body/url (use recipients/title/message/actionUrl) and script functionName/input (use function/inputs). All are pure key renames with unchanged values and replay losslessly. Like the sharing-rule access level above they keep a load-path acceptance window: none carried a prior deprecation warning, and FlowNodeSchema.config is an unconstrained record, so no schema tombstone can reject them — the conversion layer is the only seam that can declare, convert, and retire them.
The same graduation covers wait, whose fallback was not a config-to-config rename. wait keeps its contract in the declared waitEventConfig block, not in config at all — yet the executor also read six loose config keys, two of them (duration, signal) spellings the spec never declared. The conversion lifts them onto the declared block in the executor's own ?? precedence, so a value already declared wins and its loose counterpart is left shadowed. One wrinkle makes this a rewrite rather than a delete: waitEventConfig.eventType is required once the block exists, and the loader parses the CONVERTED flow — so a source carrying only config: { duration } is stamped with eventType: 'timer', the exact default the executor applied to that shape. Behaviour-preserving in both directions.
connector_action gets the same lift for the opposite reason. Its contract also lives in a declared sibling block (connectorConfig), and the executor never read config at all — but the node's descriptor published a configSchema declaring connectorId/actionId/input as config keys, and the Studio inspector derives its form from a published schema, so schema-driven authoring wrote the trio to the wrong place and produced nodes that refused to dispatch. The conversion lifts the trio onto the declared block (declared keys win; a lift that cannot complete the required connectorId+actionId pair leaves the node untouched rather than turning a step-time refusal into a load failure), and the descriptor stops publishing the mis-rooted schema.
The reconciliation that found those also found map, whose executor read a bare cfg.flowName ?? cfg.flow for an undeclared flow spelling no schema ever described. A pure rename, graduated the same way, so the executor reads only the canonical flowName.
And it removes the RLS-policy key priority (found by the liveness ledger's security re-verification): promised "conflict resolution" that cannot exist, because no outcome depends on an order: applicable policies OR-combine on reads, and a write's check is chosen once per operation across the applicable policies, then OR-combined (see RowLevelSecurityPolicySchema.check) — there is never a conflict to order, and nothing ever read the key (call graph closed across the collection site, the projection round-trip and the compiler). A pure lossless delete: outcomes are identical with or without it; the schema tombstones the key with the same prescription.
The same close-out retires the four inert tool authoring keys (category, permissions, active, builtIn): none is part of AIToolDefinition and no execution path read them. Two were misleading in the dangerous direction — permissions promised an invocation gate nothing enforced, and active: false read as "withdrawn" while the tool kept reaching the LLM tool set. Lossless deletes; the strict ToolSchema rejects each with its prescription.
The AppSchema sheds its seven dead authoring keys (2026-06 liveness audit, the unknown-key strictness wave's app step): version (apps are versioned by manifest.version), aria, objects/apis (the self-described "config file convenience" — nothing read them; the chatbot derives an app's objects from its nav items), sharing/embed (a declared-but-unenforced public surface — the only live path is FormView.sharing; ADR-0049), and mobileNavigation (fully unimplemented). Pure lossless deletes — none ever had a runtime effect; each key is tombstoned with its prescription.
ADR-0113 splits the required tri-binding: post-17, required is ONLY the write-time contract (insert must provide; update may not null out; legacy null rows rest), and the physical NOT NULL is the explicit storage.notNull. ⚠️ NOTHING converts the column half for you, and nothing tightens a column you already have. A field-required-notnull-explicit conversion did stamp storage.notNull: true onto every required: true field; it was WITHDRAWN (maintainer ruling 2026-09-08), because stamping the constraint wherever required: true appears is exactly the implication the ADR abolished — and because the artifact-ingestion door replays retired conversions, so the LOADER applied it to 17-authored sources that deliberately omit it and then told their authors to write the same tightening into the source, which on a populated database is a destructive tighten_not_null migration prescribed as the remedy for a deprecation notice. Post-17 a column is NOT NULL because its author wrote storage: { notNull: true }, and for no other reason. If you are upgrading a pre-17 source whose columns ARE NOT NULL and you want them to stay that way, add storage: { notNull: true } to those fields yourself — deliberately, and knowing that doing it to a field whose column is currently nullable is a destructive migration with a backfill ceremony.
On the wire contract it also retires the /analytics/query request ENVELOPE outright: AnalyticsQueryRequestSchema used to describe { cube, query: {...}, format } — the dialect of the degraded analytics shim (retired for serving unscoped aggregates) that the real engine never understood (an envelope body inferred a column-less cube and died as an SQL syntax error). The canonical request body is now the BARE AnalyticsQuery — cube + measures at the top level — which is what every real caller already sends; the schema tombstones query/format, and the dispatcher entry validates bodies and answers 400 with the prescription. No stored metadata carries this shape (it was HTTP-only), so the change is two semantic TODOs for API callers rather than a stack conversion.
The close-out sweep finishes the enforce-or-remove worklist across the remaining types: action shortcut/bulkEnabled (no keydown path; the multi-select toolbar reads the view's bulkActions), flow active/template/node outputSchema/errorHandling fallbackNodeId (active: false never stopped a flow — status is the enforced lifecycle; faults route via per-node fault edges), the inert view keys (list responsive/performance, form data/defaultSort/aria — list aria/data stay live), dashboard and widget aria/performance, agent.knowledge (declaring sources never scoped retrieval — absorbs the former topics→sources rename), and skill.triggerPhrases (phrases were never matched; routing is triggerConditions + the agent allowlist). All pure lossless deletes, each tombstoned at its schema with the prescription.
One flow key changes WITHOUT a lossless target: errorHandling.maxRetries — it carried two defaults: .default(0) in FlowSchema and maxRetries ?? 3 in the engine's retryExecution — and because ?? fires only on undefined, an unstated count meant 0 retries for a flow parsed by the schema and 3 for a definition handed to the engine directly: the retry count was a function of the route in, not of the authored flow. The engine's copy is deleted (it reads the parsed block, no fallback), which makes an unstated count unambiguously 0 — and strategy: 'retry' that retries zero times is strategy: 'fail' under another name, the declared-not-delivered shape ADR-0049 exists to close. The schema therefore requires maxRetries at least 1 under 'retry', in both spellings (omitted, and an explicit 0). This is the one v17 flow change the chain cannot apply for you: choosing the count is a judgment about re-running the WHOLE flow with its side effects, so it is a semantic TODO rather than a rewrite.
It also retires api.requireAuth: the deployment-wide opt-out that let a stack serve its ENTIRE data plane anonymously with one boolean. Auth is a kernel concern, not a deployment posture — anonymous access to object data is now denied unconditionally on every HTTP surface. Every surface that legitimately serves a session-less caller derives its own narrow authorization from a DECLARATION instead: the control-plane allowlist, publicFormGrant (public form views), share-link tokens (read as SYSTEM), and book.audience: 'public' (ADR-0046 §6.7). The key is dropped with a notice rather than mapped — there is no replacement value, only a different way to publish (by declaration). A stack that mounts no auth at all now fails at boot when it would serve a data API, instead of receiving an implicit fail-open.
The same major retires BatchOptions.validateOnly: a batch "dry-run" flag that was declared but never implemented — every batch surface (updateManyData / deleteManyData / batchData) persisted regardless, so a caller sending it to PREVIEW a mutation got it executed. That is the dangerous direction of declared ≠ enforced: a flag lying about a data-safety guarantee. No dry-run exists today; the schema tombstones the key with the prescription. It is HTTP-only (never stored in stack metadata), so the change is one semantic TODO for API callers rather than a stack conversion.
The batch response rows converge on their declared schema in the same window: the per-row results of /batch, /updateMany and /deleteMany used to carry a legacy implementation shape — error: string, record, no index — while BatchOperationResultSchema, the published client SDK type and the reference docs all declared errors: ApiError[] / data / index. A consumer written against the declaration read row.errors and got undefined at runtime — the exact "photographed from the schema, dead on the wire" failure the enhanced-api-error rename above records. The wire now delivers the declared shape, and the ADR-0119 D4 rollback marking is structured with it: ROLLED_BACK: / NOT_ATTEMPTED: message prefixes become first-class ApiError.code values, so clients branch on errors[0].code instead of regexing message strings. A RESPONSE surface, never stored in stack metadata, so there is no source for the chain to rewrite — one semantic TODO for readers of the old row keys.
It also narrows QueryAST.fields to field names: the FieldNode union carried a second { field, fields, alias } nested-select member that nothing produced and nothing consumed — every reader on the path treats the list as string[], so the object form was dropped by the SQL and memory drivers, projected as a column named "[object Object]" by MongoDB, and refused by the REST ingress as an unknown field of that name. expand is the one spelling for nested selection (ADR-0049 enforce-or-remove; Prime Directive #12: one capability, one contract). Like the two above it is a request shape, never stored, so the chain has no source to rewrite.
The QueryAST liveness sweep applies the same method to the rest of the request surface: query.joins and query.windowFunctions are tombstoned — no engine or driver ever read either on the query path, so every join and OVER clause a caller declared was silently dropped. Joins were the second, broken spelling of related-record retrieval (expand is the live one; the whole JoinNode cluster goes with the key), and window functions only ever ran behind SqlDriver.findWithWindowFunctions(), a driver-level door whose flat input shape the spec vocabulary never matched (it declared field/over/frame members the door never read — that cluster goes too). Request shapes again: two semantic TODOs, no source rewrite.
That sweep's close-out settles the remaining three. having is ENFORCED, not removed — the engine applies it after aggregation on both paths, so the clause every SQL-literate author expects now works (no migration; queries that carried it were silently returning every group and now filter as written). cursor and distinct are tombstoned WITH their shipped SDK producers (QueryBuilder.cursor() / .distinct() are deleted): no driver ever implemented keyset pagination or SELECT DISTINCT, cursor re-served page 1 forever, and distinct's only observable effect was mis-wired — it suppressed the REST list count, which is now truthful again. Both are request shapes; two more semantic TODOs, no source rewrite.
The same kind of retirement covers wait's timeout pair. waitEventConfig.onTimeout had ZERO readers — no path ever inspected it, so neither fail nor continue ever happened, while its .default('fail') stamped a decision nothing made onto every wait node. waitEventConfig.timeoutMs said "maximum wait time before timeout" and its only reader used it as the timer DURATION when timerDuration was absent: it did something, just not what it said. Together they declared a timeout wait does not have — the run resumes when its timer elapses or its signal arrives, never on a deadline. Rather than retrofit an implementation to fit two keys that happened to be declared, the pair is retired and real timeout semantics are left to be built to a requirement. timeoutMs converts to timerDuration (stringified — the target is z.string() and parseIsoDuration reads a bare numeric string as milliseconds, so the wait is unchanged); with timerDuration already set it is dropped, having been dead metadata. Like the other keys retired for MISDESCRIBING themselves rather than for being renamed, both leave the load path: absorbing them silently would let an author keep believing they configured a timeout.
Closing the same audit on the data side, datasource.readReplicas is removed. It described replica connections nothing ever opened: ConnectableDatasource and DatasourceConnectionSpec carry no replicas field, the driver factory never reads the key, and no query path distinguishes a read from a write — read/write splitting does not exist in the platform, so every statement always went to the primary. A lossless delete with no target to move to; front replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point config at it. Notable as the case that shows how a key gets MORE convincing as it stays dead: the change that closed the datasource-config gap taught the schema to validate each replica entry against the declared driver's config contract, so sources written in between carry replica blocks that were genuinely checked — precise hosts, correct port types, typos rejected. Precision applied to an inert slot reads as evidence the slot is live, which is why ADR-0049 asks for a consumer rather than for rigor. Retired from the load path with the rest of the keys that misdescribed themselves.
The datasource close-out also graduates the four legacy datasource.config spellings the shared driver factory still tolerated via undeclared read-side ?? fallbacks (a follow-up to that validation change): sqlite file/database (use filename), postgres/mysql connectionString (use url) and user (use username), and mongo uri (use url) and user (use username). That validation change made the authoring gate reject each with a rename hint, but a runtime datasource persisted in sys_metadata before the gate kept working only because the factory read leniently — and deleting that tolerance without a conversion would have silently moved data (a stored sqlite file: row falls back to :memory:). The datasource-config-driver-key-aliases conversion rewrites the stored shape to the canonical keys at every rehydration seam, the factory now reads exactly one spelling per key, and the four ?? chains are deleted. Driver-aware by construction: database renames only under sqlite, where it aliased the file path — for every other driver it is a canonical key and is untouched. Retired from the load path not for lying but because the authoring gate already rejects the spellings loudly; the chain and the stored-row replay are the seams that accept them.
Finishing the same datasource surface, the canonical driver id mongo is renamed to mongodb. The two spellings have both been accepted since datasource.config was first parsed against its driver's own contract, and both still are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and that string is published as DRIVER_CATALOG.id and is what the Studio connection form writes into datasource.driver. Every row written before the rename therefore carries mongo while the form now emits mongodb, leaving one deployment with two spellings of one driver and any reader that matches a stored driver against the published catalog id silently missing the older rows. The datasource-driver-mongo-to-mongodb conversion converges the stored value at every rehydration seam; it stays on the LIVE load path (unlike the config-key aliases beside it) precisely because mongo is still legal — there is no loud rejection for it to pre-empt, and nothing to lose by converging early. The rename is what let the driver-selection id and the config-contract id become one string: packages/spec's driver vocabulary is now a single table both boot hosts read, which closed the last fork where OS_DATABASE_DRIVER=pg booted under os start and was refused by os migrate. turso/libSQL joins the same table with a real config contract, so a libSQL config is validated instead of waved through.
The script flow node converges on its one real path. It had four ways to name what it ran and only one of them ran anything: config.actionType: 'email' | 'slack' were logger-backed stubs that wrote a line, reported success and delivered nothing under any configuration — with config.template / .recipients / .variables feeding a message no channel ever sent; inline config.script was recognized and never executed (the built-in runtime has no server-side JS sandbox), so the node warned and no-op'd; and every other actionType value was shorthand for a registered-function name, a second spelling of config.function. All five keys are retired and function becomes required, which is also what finally made the contract PARSEABLE: while the legal key set depended on actionType, a flat parse would either reject valid shapes or wave everything through, so script (with subflow) now runs through the same execute-time config parse() the executor wiring gave the flat builtins. A shorthand actionType CONVERTS into function — that is what it meant — unless function is already set, in which case it was dead metadata the executor never reached. The other four are dropped outright: nothing read them, so there is no value to preserve, and rebuilding the intent is an authoring decision the tombstones prescribe per branch (a notify node for mail — it delivers through the messaging service, the in-app inbox by default and real email once @objectstack/plugin-email is installed; a connector_action with the Slack connector, or an http node posting to a webhook, for Slack; a registered function for an inline body). Retired from the load path for the same reason as the rest: absorbing actionType: 'email' silently would let an author keep believing the flow sends mail.
The same audit reaches the driver contract itself: IDataDriver.findStream is removed from the contract. It was REQUIRED — every driver and every test double had to implement it — and documented as the read "optimized for large datasets to avoid memory overflow", while two of its three implementations awaited find() for the whole result set and then yielded it row by row, reaching exactly the peak it promised to avoid; the third streamed for real but was the one read in that driver that skipped buildFindOptions, so it dropped query.fields. Nothing anywhere called it, which is why a contract method could carry an inverted guarantee for this long and why ~20 test doubles could satisfy it by throwing not implemented. Paged find() is the read that exists and is enforced (its total-order guarantee is checked by the shared pagination-conformance cases); a cursor-based read is worth building when a caller asks for one, which is the honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no source rewrite, and no tombstone: DriverInterfaceSchema describes a contract that code IMPLEMENTS and nothing ever .parse()d a driver, so tsc is the only channel that could carry the prescription, and it carries it where it matters — at a call site.
Separately, object.managedBy: 'system' is retired in favour of 'system-data', finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 engine-owned objects moved to the new explicit engine-owned, and the 8 admin/user-writable ones — the RBAC link tables, sys_user_preference, the three messaging config grids — stayed behind on system. What was left is a value whose name describes the half that had already moved out: "system" sitting on precisely the objects a user writes. That is not a cosmetic complaint. An author choosing between system and engine-owned had nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone reading the name in good faith — a model author most of all. system-data states both boundaries: the SCHEMA is the platform's (versus platform, which is tenant-modelled), the DATA is the admin's or the user's (versus engine-owned, where the engine owns both). Reusing config was considered and rejected — sys_user_preference is user-owned rather than admin-authored, and config suppresses CSV import — as was platform-data, which sits one word away from the unrelated platform in the same closed enum and would reintroduce the confusion at the point of choosing. Because v16 already drained the engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement call. One deliberate consequence: system defaulted LOCKED and each object re-opened its writes through userActions, while system-data defaults WRITABLE on create, edit, delete and exportCsv, so those blocks become redundant and are deleted (keep userActions only to NARROW). CSV import is the one verb that default deliberately withholds (maintainer ruling 2026-08-03): it stays opt-in per object via userActions: { import: true }, so a v16 system object — which resolved import: false, because the re-open blocks only ever named create/edit/delete — keeps resolving import: false after the rename. The reason is leverage, not authorization: three of the eight members are the RBAC link tables, and a bulk-grant entry point on the permission model's grant surface should be a per-object declaration rather than something inherited by being filed in the right bucket. No enforcement moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all adjudicate off resolved affordances and the principal, never off the bucket name; system-data simply joins platform/config as a bucket the guard does not cover, because a writable default has nothing to fail closed on. Retired from the load path: the enum rejection is what teaches the new spelling, and absorbing 'system' silently at load would leave every author writing the name this rename exists to retire.
Finally, five keys retire because the advisory lint could never have warned about them — mapping extractQuery / errorPolicy / batchSize, and app contextSelectors[].includeAll / .placement. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: extractQuery promised an export path no exporter implements (exports go through the ordinary query API); errorPolicy offered skip/abort/retry where error handling belongs to the import REQUEST; batchSize sized batches the write path sizes itself; placement offered a topbar that places nothing. includeAll is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. STUDIO_APP authored includeAll: true against a renderer that ignored it. The mapping prescription for batchSize deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor batchSize are all live, but each is a different key sizing its own path — the same trap datasource.retryPolicy vs hook/job retryPolicy had to defuse one issue earlier.
The sharpest removal in this step is two keys wide: app.areas[].visible and app.areas[].requiredPermissions. Read the class before the count — these were not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the server-side authority (filterAppForUser) checked the app's requiredPermissions and then walked ONLY the top-level navigation tree; it never read item.areas at all, and the client rendered every area in the switcher. So an author writing requiredPermissions: ['sales.admin'] on an area got a clean parse, a stored value, and an area visible to everybody — and had every reason to believe otherwise, because the SAME key names are genuinely enforced one level up and one level down: app-level requiredPermissions drops the whole app server-side, and a navigation ITEM's requiredPermissions / requiresService are stripped server-side and re-checked in the shell, whose item-level visible is a real CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was weighed and deliberately not taken here: it needs semantics decided first (does filtering an area remove its items everywhere? does the server bind user for area CEL?), and a retirement must not invent an authorization mechanism — while shipping a major with the gate still declared would have kept authors writing it for all of 17.x. The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading author has to re-decide is only where the gate really goes: onto the items inside the area, or onto the app — and BOTH of those destinations are server-enforced. The caveat this prescription used to carry, that per-item gating INSIDE an area was enforced by the shell only because the server did not walk areas, was CLOSED by a server-side fix inside this same 17.0.0 window: filterAppForUser now runs the SAME filterNav over every areas[].navigation, so an ITEM's requiredPermissions / requiresService is stripped server-side in BOTH trees and a gated entry never ships in the /meta body at all. Read that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired and that fix gave an area no gate of its own — what it enforces are the items inside one. The other half of the asymmetry is unchanged and is why requiredPermissions is the key to reach for: visible (CEL) and requiresObject are still evaluated client-side ONLY at every level, because server-side CEL needs a bound user context the read layer does not have — so anything that must never reach the browser goes in requiredPermissions, never in visible.
The same window converges the retry policy. @objectstack/spec/automation and @objectstack/spec/system each exported a RetryPolicy/RetryPolicySchema resolving to a DIFFERENT declaration, so which shape a consumer got depended only on the import path — yet both computed delay = base * multiplier^(retry-1) and both executors implemented that same formula. One declaration now serves both entries with the union of their capabilities, so job.retryPolicy gains the maxRetryDelayMs ceiling and jitter (both enforced in runWithPolicy, not merely declared — jitter is what stops a fleet of jobs that failed on one outage from retrying in lockstep). The single authorable casualty is the automation spelling of the base delay: retryDelayMs → backoffMs, a pure rename that replays losslessly and is what the already-enforced retry policies (job.retryPolicy, hook.retryPolicy) call it.
The subtle half is the defaults, and it is worth stating because no gate can see it: job.retryPolicy defaulted maxRetries: 3 / backoffMultiplier: 2 while the automation shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed default is invisible to it, to the tombstone mechanism and to spec_changes alike. The merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same reading already recorded in flow-retry-max-retries-required), and the conversion writes the pre-17 numbers into every existing job.retryPolicy that omitted them. Deployed stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored omission means.
That convergence then had to be finished twice more, and WHY it was incomplete is the part worth carrying forward. It was driven by the dual-source instrument, which asks "how many declarations publish the same exported NAME?" — so it could not see the two encodings of the identical policy that have no exported name at all, being anonymous inline blocks nested in a bigger schema: flow.errorHandling and ETLPipeline.retry. The instrument was not broken and answered its own question exactly; that question was simply not "how many shapes does this ONE concept have?", which is what everybody read off it. The cost of the gap is concrete and falls on the author who did the right thing: shared/retry-policy.zod.ts tombstoned retryDelayMs and told them to write backoffMs, and flow.errorHandling then rejected backoffMs and demanded retryDelayMs — reading the newer file was punished. Both blocks now build from one shared shape. flow.errorHandling costs nothing beyond the same retryDelayMs → backoffMs rename (every other key, bound and default already matched, which is exactly why it looked reviewed), and the conversion covers it. ETLPipeline.retry costs a rename of the COUNT — maxAttempts → maxRetries, same number, do NOT subtract one: that adjustment belongs to integration/connector.zod.ts's identically-spelled RetryConfig.maxAttempts, which INCLUDES the first attempt — plus the same default flip (3 → 0) and three keys it never had (backoffMultiplier / maxRetryDelayMs / jitter, so a nightly warehouse pipeline can stop retrying flat, uncapped and unjittered every 60s). The ETL half gets a tombstone and no conversion step, deliberately: an ETL pipeline is not a defineStack collection and etl.zod.ts has no parse site in any of the three repos, so there is no stored document to walk and a step for it would advertise coverage it does not have. Nothing deployed moves; the migration surface is empty and this is the cheapest this convergence will ever be.
The same enforce-or-remove pass reaches the event vocabulary: DataEventType drops data.field.changed. It had no producer anywhere — the engine emits data.record.{created,updated,deleted} and, since predicate writes got their own bulk event, data.records.{updated,deleted} — so a subscriber switching on it held a branch that could never run, and the switch still compiled, which is why an empty member could sit in a public enum this long. It could not have been implemented against this contract as written: DataEventSchema is record-shaped and has no field / oldValue / newValue slot, so the member advertised a granularity the payload has no room for. Nothing is lost — per-field detail already rides on data.record.updated as changes (with before / after), one event per write instead of N on a wide table. Like the driver contract above it is a runtime surface, never stored in stack metadata, so it is one semantic TODO for event consumers rather than a source rewrite, and it carries no tombstone: a removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule full retirement noted. Should a real per-field stream ever be wanted, it earns its own contract, as the bulk data.records.* events did, rather than reclaiming this slot.
The object capability block closes out the same ADR-0049 pass: enable.trash and enable.mru left the schema in the 16.x line (the close-out of the 11.0 dead-property removals — every delete has always been a hard delete and MRU tracking was never implemented, so both default-true flags gated nothing), and the .strict() capabilities block rejects them with the prescription. This step registers the migration surface that removal was missing: stored 16.x rows replay clean instead of flagging metadata_spec_invalid, and os migrate meta --from 16 lists the mechanical edits for authored sources. Soft delete stays parked at the proposal stage; if built it returns as a live enforced flag rather than by reviving these keys.
The same enforce-or-remove pass retires the RestServerConfig.openApi31 block: OpenApi31ExtensionsSchema (webhooks / callbacks / jsonSchemaDialect / pathItemReferences) with OpenApiWebhookEventSchema and CallbackSchema under it. Declared-but-unenforced end to end: the REST server's normalizeConfig forwards only api/crud/metadata/batch/routes, the served /openapi.json is the pre-generated contract enriched with the live server URL and registered objects, and gen:openapi never read a webhook or callback — so a definition authored under openApi31.webhooks never appeared in any served document, and zero import-level consumers existed across objectstack / cloud / objectui. RestServerConfig is plugin TS configuration (the REST plugin constructor / plugin-hono-server restConfig), never a stored metadata shape: the stack tree's own api block declares only its four scoping/auth knobs, so no sys_metadata row can carry openApi31 and there is no source for the chain to rewrite — one semantic TODO for config authors rather than a stack conversion, the validateOnly shape. The key itself is tombstoned (the schema is not .strict(); a plain delete would strip it silently), and a config-driven webhooks/callbacks synthesis, if ever wanted, returns via the enforce route of ADR-0049 through a new ADR.
The same pass closes activationEvents: both keys that carried it — DynamicLoadRequest.activationEvents on the kernel side and StudioPluginManifest.activationEvents on the studio side — declared lazy plugin activation ("plugins remain dormant until an activation event fires") that no runtime in any repo ever implemented: every plugin has always activated immediately on load/registration, and cloud-v1's own ROADMAP recorded the capability as unimplemented, planned for v0.4.0. A dual-source cleanup had just converged the two ActivationEventSchema declarations onto one structured { type, pattern } vocabulary in this same unreleased major; with the maintainer's enforce-or-remove ruling landing on REMOVE, that converged vocabulary retires before ever shipping — composed across the two changes, a v16 author simply deletes the key in whichever form they carried. Neither parent is stored metadata (StudioPluginManifest is TS configuration parsed by defineStudioPlugin; DynamicLoadRequest is a runtime request shape with no caller in any repo), so there is no source for the chain to rewrite — one semantic TODO, the validateOnly shape. The kernel key is tombstoned (its schema is not .strict(); a plain delete would strip it silently), the studio key is rejected by the strict manifest parse with its own guidance prescription, and the orphaned ActivationEventSchema def is removed with them. Behaviour is byte-identical: eager activation was always the only behaviour.
That kernel-side tombstone was then SUPERSEDED inside the same unreleased major by a removal that finished the enforce-or-remove pass one level up: the entire plugin-runtime.zod family — DynamicLoadRequest, DynamicUnloadRequest, DynamicPluginResult, PluginSource, DynamicPluginOperation — is removed, because the "Dynamic Loading" capability it described (runtime load / unload / reload without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy) has no server anywhere: no runtime in objectstack, cloud or objectui ever received one of these requests or produced one of these results. An earlier removal from that module had suspended the call on these five deliberately — "operation contracts, not security promises" — in a changeset paragraph no issue carried; this removal is that decision, answered REMOVE. So a v16 author who wrote activationEvents inside a DynamicLoadRequest value does not delete a key: the whole value has no shape and no recipient, and importing DynamicLoadRequestSchema at all is TS2305 in v17. The studio half of the activationEvents retirement is untouched and still rejects the key with its own prescription — defineStudioPlugin remains a live authoring surface. Behaviour is again byte-identical: nothing ever executed a dynamic plugin operation.
Finally it removes the script-body capability token 'crypto.hash'. Four layers declared it — the HookBodyCapability enum, the doc table beside it, the CLI extractor and ScriptContext.crypto.hash — and none implemented it: installCtx wired only randomUUID, so the one call the token authorised threw inside the VM every time. The build-time inference made it worse than an ordinary declared-but-unenforced key: writing ctx.crypto.hash(...) made the CLI ADD the capability for you, so os build went green on the body that was guaranteed to fail at the first record write. Removed rather than implemented (ADR-0049) — hashing inside the sandbox widens its capability and security-review surface, and a capability that throws on every use yet drew zero complaints in its whole life is its own liveness verdict. This is an enum VALUE, not a key, so there is no retiredKey() tombstone: the enum error map carries the prescription, keyed on the received value so that only the spelling which used to be legal is told it "was removed". The conversion strips the dead token from body.capabilities on hooks and actions; it deliberately does NOT touch the ctx.crypto.hash(...) call the body made under it, which never returned a value and which the author must delete. Hashing returns only WITH an implementation, through the capability admission process.
It also removes connector.rateLimitConfig and its whole shape. This one is not "declared but unread" — it is declared but UNIMPLEMENTED, one step worse. The only token bucket the platform owns (runtime security/rate-limit.ts) is INBOUND: the dispatcher calls consume(key) on a request fingerprint and answers 429. Nothing anywhere throttles the calls a connector makes OUT, and no provider — connector-rest, connector-openapi, connector-mcp, connector-slack — reads the key or has a seam that could. So strategy, maxRequests, windowSeconds, burstCapacity, respectUpstreamLimits and rateLimitHeaders parsed cleanly and capped nothing, on a surface where the author believed they had bounded their spend against a third party's quota. ConnectorRateLimitConfig and the RateLimitStrategy enum it embedded had no other consumer and are removed with the key, so importing either is TS2305 in v17 — the plugin-runtime.zod shape, and the same implementation-first ruling: the vocabulary comes back WITH the engine, in one change. It is deliberately NOT converted to shared RateLimitConfig, which limits the calls others make to US; the dual-source cleanup split their names for precisely this confusion, and rewriting an outbound cap into an inbound one would throttle the wrong direction. Delete the key and rate-limit where the calls are actually made — the connector provider or upstream gateway.
Last, it removes dashboard.widgets[].responsive — the straggler of the close-out sweep above, which retired the literally same-named view.responsive on the same evidence four days earlier. Re-measured before removal: no objectui code reads widget.responsive (DashboardRenderer, DashboardEditor and plugin-designer name it only in comments), and there are zero authored instances repo-wide, so the conversion is expected to be a no-op on every real source — it exists so that a stored dashboard carrying the key is cleaned deterministically rather than meeting the tombstone at load. What kept it alive was not evidence but a hole in the instrument: the liveness ledger declares no children on dashboard.widgets, and the walk only drills one level through an explicit children, so no widget-level key has ever been classified at all (filed as its own finding, fixed separately). The removal was deliberately narrow — it took the widget EMBED, not the shape, which at the time was believed live on page.components[].responsive via objectui useResponsiveConfig. CORRECTED in 2026-08: that belief measured false — the hook had zero callers, nothing read the page key either — so protocol 18 retires page.components[].responsive and the ResponsiveConfig shape with it (see the page-component-responsive-removed step-18 entry). Authors who need breakpoint behaviour use responsiveStyles (ADR-0065), the channel objectui really compiles. Per-widget responsive layout returns if and when a renderer implements it.
Finally it CONVERGES dashboard.widgets[].compareTo — the one entry in this step that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. The widget declared three arms with confident TSDoc; the analytics executor implements one contract, DatasetSelection.compareTo = { kind, dimension? }, which has no offset in it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a comparison silently absent from a widget whose author asked for one) and { offset } was forwarded into that contract with no dimension, so the executor threw compareTo requires a timeDimension "undefined" and errored the whole widget. All three arms worked on the legacy inline chart path. Same key, two fates — and the failing one was the path the spec itself calls canonical, which is why this ranks above an ordinary declared-but-unread key: the documentation was actively teaching a shape that crashes. The widget now declares the executor's own words, so declared = enforced holds by construction with no second vocabulary left to drift. dimension is optional and resolved by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error naming the candidates), which is a producer-side resolution rule, not the consumer-side tolerance PD #12 forbids. The bare strings and { offset: '1y' } replay mechanically; every other { offset } duration is a semantic TODO below, because previousPeriod shifts by the resolved window's own length and rewriting 7d into it would change which rows the comparison counts. The converged slot is also union-free, which is not cosmetic: zod collapses a failed union into one bare Invalid input, and a top-level-only error mapper showed that curated guidance inside a union arm never reaches the author at all.
The same widget drill retires four more keys: the action trio actionUrl/actionType/actionIcon, and aria. The trio described a per-widget action BUTTON that no renderer in either repo has ever drawn — all 14 actionUrl reads in DashboardRenderer are scoped to header.actions[], a different schema — and actionIcon had zero references anywhere outside its own declaration. aria is the dashboard-level aria removed by the close-out sweep, one level down: declared ARIA attributes that never reached the DOM, i.e. an accessibility guarantee an author could state and nothing honoured. It survived that sweep for the same reason responsive did — widgets had no ledger drill until the instrument hole above was fixed — not on evidence. This removal also settles a second-order cost the trio was carrying: packages/lint's dashboard action-ref rule enforced ERROR-severity reference integrity on widgets[].actionUrl, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not. That widget branch is deleted with the keys. Lossless deletes in every case — the keys contributed nothing to any rendered output — and the shared AriaProps shape is untouched, staying live on page.aria / page.components[].aria and the list view aria — not on app.aria, which this same major retires (see app-dead-authoring-keys-removed, which strips it). Move a dashboard-wide affordance to header.actions[] (where icon is the header spelling of actionIcon); for per-row click-through use a dataset-bound table/pivot, whose rows drill through the semantic layer already.
⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read first: declarative apis: endpoints EXECUTE from 17. The surface used to be inert end to end — no route mounted, no matcher, every key including authRequired parsed and enforced nothing — which is why publish at first refused a non-empty apis: outright. 17 ships the executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that passes the gate is MOUNTED and serves traffic the moment it is published. Any historical apis: block therefore changes meaning without changing a byte. Review every entry before upgrading, and pay particular attention to an explicit authRequired: false: the schema default is true, so an omission is safe, and only that explicit false opens anonymous access — which ADR-0121 D6 now pairs with a mandatory armed rateLimit (enabled: true; the key defaults to false, so a budget written without it meters nothing). Paths also move under the namespace carve-out /api/v1/apps/<manifest.namespace>/<subpath> (ADR-0121 D1/D2). The full checklist is the declarative-apis-endpoints-live semantic entry below; it is a security review, not a rename, so nothing about it is applied for you.
Finally, the theme token scales retire (ADR-0049): typography.fontSize, typography.fontWeight, typography.lineHeight, typography.letterSpacing, typography.fontFamily.heading, typography.fontFamily.mono, animation and zIndex. These are the reverse of the usual inert key and the distinction is the point: the theme engine DID emit them — --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading, --font-mono all reached the document exactly as authored — and no first-party component or stylesheet has ever read one, so a declared type scale was real CSS that styled nothing. That is why the earlier theme sweep left them standing: its criterion was "never emitted", and these are emitted. colors, borderRadius, shadows and typography.fontFamily.base have live consumers and are untouched. The prescription is customVars, which emits --<key>: <value> verbatim — so a tenant stylesheet that really was reading --z-modal reproduces it byte for byte and loses no capability. The conversion DELETES the keys and emits a notice per key rather than auto-populating customVars: a rewrite would hand back two dozen variables that still nothing reads, turning a dead semantic slot into a dead literal one. Deciding which of them you actually consume is yours to make; the notice names each one. Retired from the load path with the other keys that misdescribed themselves.
It closes the enforce-or-remove line with two ./ui vocabulary shapes that never had a key to be written into: NotificationActionSchema / NotificationAction and EmbedConfigSchema / EmbedConfig. This is the class BELOW a declared-but-unread key — there was no key at all. No schema anywhere declared a carrier, a BFS from all 24 metadata-type roots plus ObjectStackSchema reached neither (with Page / Action / DashboardWidget / Webhook / SharingConfig as positive controls in the same run, and a synthetic carrier flipping both), and no repo parsed either outside its own unit test. Each was left behind by an earlier retirement one level up: the notification action by the dual-source cleanup that deleted the two wrapper shapes that could have carried it, and the embed config by 17.0.0's own App.embed tombstone. The strictness wave's 批 14 measured both and deliberately declined to close them with .strict(), because strictness on a shape nothing parses enforces nothing and only makes a dead slot look load-bearing; this removal is that deferred call, answered REMOVE. Nothing is applied for you and nothing needs to be — there is no key in any source to rewrite; the change is visible only as TS2305 on an import. ⚠️ Read the scope precisely, because one of the two modules SPLITS: ui/sharing.zod keeps SharingConfigSchema and it stays LIVE — FormView.sharing carries it and rest-server.ts mounts the anonymous form routes on allowAnonymous + publicLink, so public form sharing is untouched — and ui/notification.zod keeps NotificationType / NotificationSeverity / NotificationPosition. Only the two named shapes go.
The same is true of the protocol-17 retirement that closes this list, and the pair is worth reading together (ADR-0049): the five @objectstack/spec/ui interaction-config modules — touch.zod.ts, dnd.zod.ts, keyboard.zod.ts, animation.zod.ts and offline.zod.ts, 22 z.object sites and 64 exported names — are deleted whole, with their reference docs. They were never reachable: no schema in the protocol declared a touch: / dnd: / keyboard: / animation: / offline: key, so no metadata document could carry one and none needs rewriting now. The defect was on the DOCUMENTATION side, which is the half that made it urgent — authorable-surface.json carried 109 keys under these defs and the generated references/ui/* pages rendered them as authoring tables, so an AI author reading dnd.mdx wrote a dnd: block that PageComponentSchema then rejected as an unknown key. That is a published capability the runtime does not deliver (Prime Directive #10), not a strictness gap: closing the shapes would have validated a slot nobody can reach. Business reading behind the ruling: these five are RENDERER BUILT-IN behaviour, decided by the component library rather than authored per page; offline is a platform capability whose vocabulary belongs on a sync engine that has not been built. ⚠️ The animation here is ui/animation.zod.ts (ComponentAnimation / MotionConfig / PageTransition / AnimationTrigger), a DIFFERENT surface from the theme animation block retired above with the token scales — that one had a carrier key and got a tombstone; this one had none and gets deletion. The one name worth checking on upgrade is the bare ConflictResolution type: it left with ui/offline.zod.ts and is now published by nobody. ConnectorConflictResolution (@objectstack/spec/integration, connector sync) and ConflictResolutionStrategy (@objectstack/spec/api, route merge policy) are different concepts under their own names and are untouched.
The last enforce-or-remove entry of this step is on the RUNTIME context rather than on anything authorable: HookContext.session.roles. It was declared in data/hook.zod.ts, read by exactly two consumers — the approvals record lock and the delegation write guard, each opening with session.roles?.includes('admin') — and produced by nobody on the hook path: ObjectQL's buildSession() writes the session field by field (userId, organizationId, accessToken, isSystem, actor, the skip flags) and has no roles write, here or in cloud, whose hook consumers read hookContext?.session?.userId and nothing else (an ACTION body's ctx.session is a different untyped object that does carry one, tracked apart). So both branches were dead on every real engine path: an authorization decision in shape only, and — worse for a reader — a SECOND admin dialect competing with the one ADR-0095 D3 sanctions. The approvals plugin deleted the two readers on the maintainer's ruling; this step removes the declaration that outlived them, which is what ADR-0049 asks for once a key has neither end. Nothing observable changes: a key nobody wrote and nothing read cannot alter a single decision. It is tombstoned rather than deleted because HookContextSchema is deliberately NOT .strict() (strictness there would make an engine-internal enrichment a breaking change for anyone parsing a context they were handed, as provenance was for schedule-triggered runs), so a plain delete would strip the key in silence — the ADR-0104 failure this whole pass exists to end. There is NO conversion and no source rewrite: a HookContext is built per operation by the engine and never stored, so no sys_metadata row, example or template can carry the key — the openApi31 / activationEvents shape, one semantic TODO for hook authors. The live vocabulary is untouched and deliberately elsewhere: gate on session.userId / session.isSystem in the hook, and judge PRIVILEGE through the security service, which reads capability grants (permissions), placements (positions) and the derived posture off the execution context.
The same enforce-or-remove reading reaches the storage contract: IStorageService.list(prefix) is removed. It had no consumer — the only in-repo call site was a proxy pass-through — and the two shipped adapters answered it with two different, silently incomplete semantics: the local adapter listed a single level and reported directories as files, the S3 adapter recursed and stopped at 1000 objects without reading IsTruncated / ContinuationToken. Enumerating a prefix without a cursor is the wrong signature to inherit, so nothing replaces it in place: query the file records you wrote, and let a real caller bring back a cursor-shaped list(prefix, { cursor, limit }) with adapter-conformance cases behind it. Same shape and same disposition as the findStream retirement above — a TS/API contract, no stored source, no tombstone, tsc at the call site.
Finally it retires the two inert IndexSchema keys, indexes[].type and indexes[].partial. Neither ever had a DDL consumer: SqlDriver.syncDeclaredIndexes creates declared indexes through knex's table.index() / table.unique(), and the drift differ's DeclaredIndexInput carries only name/fields/unique/nullSafeColumns — so an authored type selected no access method and an authored partial produced a FULL index with its predicate discarded. partial was the more damaging of the two because it read as a correctness control: the platform's own sys_metadata declared it for overlay uniqueness, and what the declaration alone materialized was an unrestricted unique index (the active-row scoping is delivered by a runtime migration, metadata-protocol's ensureOverlayIndex, not by the key). type was the louder: its .default('btree') put an inert knob into every parse output, so it read as live configuration — the ADR-0078 no-silently-inert shape. Remove was chosen over enforce (maintainer ruling, 2026-08-06): enforcing needs per-dialect algorithm mapping (gin/gist Postgres-only, fulltext MySQL-family), raw-SQL CREATE INDEX … WHERE on the dialects that have partial indexes at all (MySQL does not), and a redesign of how isSyncReproducibleIndex excludes partial indexes from incremental sync — design cost for a capability nothing has asked for. Both are lossless deletes: no DDL changes, because no DDL ever depended on them. Drift detection is untouched — the partial flag it consumes is parsed back out of the database's OWN CREATE INDEX DDL and never came from this key.
It also retires the field-mapping transform key and the whole five-member FieldMappingTransform union behind it: constant / cast / lookup / javascript / map, declared on shared/FieldMapping and inherited by integration/ConnectorFieldMapping and data/ExternalFieldMapping. Nothing ever executed one. fieldMappings is spelled only inside packages/spec itself — the connector packages, the automation engine, REST and objectui never read it, and no code anywhere switches on transform.type — so all five members were declared-but-unenforced together, not just the one that got the bug filed. That one is the sharpest evidence though: javascript's .describe() recommended the dialect js, which ExpressionDialect had already retired (ADR-0058 addendum), so the envelope the documentation taught was rejected by the enum; the only spelling that parsed was the bare string, which ExpressionInputSchema wraps as cel; and the CEL that resulted could not evaluate the value.toUpperCase() the same line offered as its example. Three surfaces disagreeing about a capability with no implementation under any of them. Fixing the sentence alone was rejected (maintainer, 2026-08-06) as gilding a member that cannot run. The key is tombstoned rather than deleted because the schema and both extenders are plain z.objects and ConnectorSchema.parse is a live receiver, so a bare deletion would strip silently. What is NOT affected, despite the shared word: the import mapping's mapping.fieldMapping[].transform, a flat string enum applied row by row by the REST import path and live in the liveness ledger — including its own javascript value, which that path rejects with a 400 rather than pretending to run.
The last of the strictness wave's enforce-or-remove batches lands on two more ui/ files (ADR-0049 — read it next to the interaction-config modules above, it is the same shape one batch later). ui/widget.zod.ts published a whole widget-REGISTRATION vocabulary — WidgetManifest with WidgetLifecycle hooks, WidgetEvents, WidgetProperty knobs and a WidgetSource npm/remote/inline implementation union — and ui/i18n.zod.ts published I18nObject, PluralRule, NumberFormat, DateFormat and LocaleConfig. Ten defs, twenty exported names, and not one carrier key between them: nothing under packages/spec/src imported widget.zod at all, the only live imports of i18n.zod name I18nLabelSchema / AriaPropsSchema, the BFS from all 24 metadata-type roots plus defineStack reached none of them, and no repo ever parsed one. So again nothing is applied for you and nothing needs to be — the change is TS2305 on an import, and a field.widget: "my_picker" string is untouched, because that key names a component the RENDERER registered and never referenced WidgetManifest. ⚠️ Read this scope precisely too, because BOTH files split. ui/i18n.zod.ts keeps I18nLabelSchema (the label primitive the whole ui/ tree imports) and AriaPropsSchema — a REAL door, carried as aria: on ~30 live shapes and closed by 批 16, untouched here. And ui/widget.zod.ts keeps FieldWidgetPropsSchema, the one site of the nine whose evidence differs: it is a React props contract rather than authorable metadata (it never appeared in the authorable surface or the schema manifest — onChange is a z.function()), so having no parse is its design; and an objectui change (2026-08-03) made it a live compile-time consumer, renaming @object-ui/fields' validation slot onto this contract's error with no alias and pinning it as a deliberate tripwire. Retiring it would have broken the one consumer the batch had, one day after it appeared. The measurement that decides a site is the CURRENT one, not the one in the issue body.
Last, it reconciles the SDUI component-props surface with the renderers that serve it — wiring the first parse gate ComponentPropsMap ever had found that the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the record:details sections rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (element:record_picker labelField/valueField/label/emptyText, record:path stages[].terminal, page:tabs items[].value/items[].count, page:card children, and children on page:section/page:footer/page:sidebar, which were declared EmptyProps while their renderers rendered a child list), and four keys retire. Two are synonym renames: element:record_picker.displayField → labelField (the required key no renderer read, while labelField ?? 'name' is what actually renders the row — so an author who followed the schema got a picker listing name with no diagnostic, the ADR-0078 shape), and page:card.body → children (one composition key across every container; the card renderer already reads both, and the showcase authors children). Two are enforce-or-remove deletions: element:record_picker.searchFields and .multiple — the control is a shadcn single-select with no search input, binding ONE record id into a page variable, so searchFields narrowed nothing and multiple: true selected nothing extra while reporting success. Either returns the day the capability is implemented. Not in scope, and deliberately: page:card.visible is a component-level visibility predicate written into properties and hoisted by the renderer — a page to rewrite onto the ADR-0089 visibleWhen, not a key to declare.
That count turned out to be incomplete, and a second pass finishes it: five more keys the renderers read were still undeclared. Four are plain additions with no behaviour change (page:header recordChrome/showStar/showCopyId, which select between the record-chip header and the bare heading a dashboard wants, and page:accordion.variant, which decides whether the accordion draws its own dividers or leaves the border to each panel). The fifth is a rename, and the only one in the family whose defect is structural rather than an oversight: the tab strip's visual style was declared as page:tabs.type, which collides with the page component's OWN dispatch key. objectui's SchemaRenderer refuses to hoist properties.type for exactly that reason, sdui-parser's BASE_PROPS contains type and skips it before any validation runs, and in a flat or JSX carrier the node reads { type: 'page:tabs', … } so the name is already taken. The key was therefore unauthorable in every carrier but the nested properties object, and unvalidated even there. It becomes tabStyle — the spelling objectui publishes and the renderer already reads first in the flat carriers — which is displayField → labelField again: converge on the spelling that works, not the one that declares well, and keep one spelling rather than two (Prime Directive #12).
A third pass closes that reconciliation from the other side, on three keys the two earlier passes left standing (maintainer ruling 2026-08-09, decision-inbox round: retire the header icon and card actions upstream, and the details layout). Two are the plain B class — declared here, read NOWHERE. page:header.icon is resolved by objectui only per header ACTION (action.icon); the header's own props bag is never asked for one, and @object-ui/layout's <PageHeader> takes an icon React prop from a host with no schema fallback beside the schema?.actions ?? schema?.properties?.actions fallback four lines away. page:card.actions has no actions area to render into at all: the card renderer builds its <Card> from title, bordered, children and footer, full stop. Both sat in objectui's own unpublished-exemption map as "spec declares it, NO renderer read point", which is what put the contract decision — wire it, publish it with a KNOWN GAP marker, or retire it — in front of the maintainer; the ruling retired it. Neither has a lossless rewrite target (a header has no second icon slot, and moving a card's action ids into children as components is a page rewrite, not a mechanical one), so both are pure strips. ⚠️ page:header.actions is LIVE and untouched — the strip is scoped by component type, never by key name.
The third, record:details.layout, is a sharper shape and the one worth reading twice: it IS read. The renderer computes schema.layout === 'inline' || schema.layout === 'compact' ? 'horizontal' : 'vertical', while the declared enum is auto | custom — so neither legal value can match, both take the same branch, and a key that was accepted and read still selected nothing, under a .describe() promising "auto uses object highlightFields, custom uses explicit sections". The behaviour that prose describes is real, but the renderer keys it off whether sections was authored, never off this flag. Every gate stayed green because check:react-declaration-parity compares two DECLARATIONS and objectui declared the same auto | custom enum — perfect agreement over a key nothing honoured — while a THIRD spelling (stacked | inline | compact) sat in @object-ui/types' mirror. A pure strip for the same reason: auto, custom and omission were behaviourally identical, so there is no value to carry. ⚠️ record:highlights.layout is a different, live, honoured key and is untouched. objectui's side of both rulings drops the exemptions, the input and the dead branch on the next pin bump.
Finally it narrows the aggregation vocabulary: array_agg and string_agg leave AggregationFunction (ADR-0049). The enum declared eight functions and the SQL family compiles five — SqlDriver.mapAggregateFunc and the Turso RemoteTransport.aggregate each lower count/sum/avg/min/max and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: service-analytics subtracted array_agg and string_agg by name in UNSUPPORTED_AGGREGATES, because without that subtraction they reached the Cube strategy's default and returned COUNT(*) — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: count_distinct STAYS and takes the enforce leg — one portable lowering (COUNT(DISTINCT x)), a dashboard staple, already lowered by service-analytics — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and string_agg never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a SEPARATOR clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with crypto.hash above — there is no retiredKey() tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites dataset.measures[].aggregate, dropping the measure outright (a measure with neither aggregate nor derived fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: compileDataset refused both by name already, so such a measure never produced a number. QueryAST.aggregations[].function is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two were inside the maintainer's driver freeze when this was decided (it was lifted on 2026-08-11) and are untouched; their code is simply no longer reachable through a spec-valid request.
The same aggregation node loses one more member, and it is the sharper class of the two: aggregations[].distinct is removed (ADR-0049, maintainer ruling 2026-08-09). The functions above were declared and UNLOWERED — a caller on a SQL datasource got a refusal. This flag was declared and lowered by exactly ONE of the six faces that read an aggregation: the engine's in-memory fallback deduplicated the values before applying the function, while SqlDriver.aggregate, the Turso RemoteTransport.aggregate, driver-mongodb's buildAggregationStage, driver-memory's computeAggregate and service-analytics' AGGREGATE_SQL all ignored it. So the same query answered a deduplicated sum on the fallback path and an ordinary sum on every SQL datasource, with the engine choosing between the two per query — by driver, by a non-UTC date bucket, by whether the driver aggregates natively at all. That is the divergence class closed on this axis when every SQL face got one aggregate spelling and one refusal, still open on this key, and it is worse to sit on because the wrong answer is a PLAUSIBLE NUMBER rather than a refusal: no error, no log, nothing for a dashboard author to notice. It survived the QueryAST sweep of this very schema because that sweep asked which members no executor reads, and this one had a reader — the wrong question for a key whose defect is WHICH executor reads it. Remove rather than enforce, per the ruling: count_distinct (which just took the enforce leg above, and whose SQL lowering has since landed) already covers the only deduplicating spelling with measured demand, while SUM(DISTINCT …) / AVG(DISTINCT …) are near-universally a modelling mistake and would have to be lowered across five faces, two of them then frozen under the driver freeze (lifted 2026-08-11, after this ruling), to buy it. The blast radius inside the fallback is narrower than the key suggests and was measured rather than assumed: only sum and avg ever changed answer — count returned from its own branch before reaching the dedupe, count_distinct fed a Set, and dedupe does not move min/max. AggregationNodeSchema is non-strict, so the key is retiredKey()-tombstoned rather than bare-deleted: a plain deletion would have made zod silently STRIP what callers still send, trading a divergent flag for an ignored one (ADR-0104). One tombstone covers every aggregation door, because QuerySchema.aggregations and EngineAggregateOptionsSchema.aggregations reuse that one schema by reference. No conversion: a request surface with no stored source — one semantic TODO below, the disposition every other data.query.* retirement in this major already takes.
One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for api.requireAuth: an omitted ActionDescriptor.resumeAuthority resolves to 'service' instead of 'any', so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare resumeAuthority on the descriptor) has to arrive before a user meets a run that will not continue.
The same descriptor loses a key in this step, and the pairing is the point (ADR-0049). ActionDescriptor.isAsync and ActionDescriptor.supportsPause were two spellings of one capability — "this node type can suspend the run" — and enforcing pauses split them by evidence rather than by preference: supportsPause took the ENFORCE leg (the engine now refuses a suspension the descriptor never declared, at the one seam every suspension passes through), and isAsync takes the REMOVE leg, because a fresh three-repo measurement found zero readers and no consumer it could grow into. What makes the duplicate worse than an ordinary inert key is that five shipped descriptors WROTE it, so the platform itself modelled a declaration that decided nothing — and a plugin author copying screen (which declared BOTH) had no way to tell which of the two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives as a rejection carrying the fix; and because a descriptor lives in executor TypeScript rather than in stored metadata, its prescription is a semantic entry below rather than a conversion os migrate meta could replay.
The plugin manifest loses its whole loading block in this step (ADR-0049, maintainer ruling 2026-08-04) — the same enforce-or-remove question asked of a block rather than a key, and answered REMOVE on measurement: every reference to manifest.loading.* in objectstack, cloud and objectui lived inside packages/spec itself, so a full loading policy parsed, entered the manifest, and configured nothing. The reason it outranked ordinary inert-key cleanup is that one of its members was sandboxing, declaring process / vm / iframe / web-worker isolation and a service ACL: an inert SECURITY control is worse than an absent one, because an author (very often an AI, ADR-0033) reads the vocabulary as proof the isolation exists and stops looking. Hot reload was a two-source defect on top of that — the retired PluginHotReloadSchema was the dead one of two vocabularies, and the ruling converges on the live one, HotReloadConfigSchema, which HotReloadManager actually reads and which is KEPT unenforced as the starting point for a separate future decision. Like isAsync, its prescription is a semantic entry rather than a conversion: a manifest is not a stack collection, so os migrate meta has no seam at which to rewrite one.
The action LOCATION vocabulary loses global_nav in this step (ADR-0049, maintainer ruling 2026-08-09). It was declared from the day ACTION_LOCATIONS was written and no product surface ever served it: the console command palette composes its groups from nav items, objects, dashboards, pages, reports, recent items and record search, and reads no action metadata at all — so an action declaring this location never reached a user. What lifts it above ordinary inert-declaration cleanup is that the authoring tool PROMISED the surface: the Studio designer previewed a mock ⌘K · Command palette frame for exactly this value, so an author (very often an AI, ADR-0033) declared it, watched it "render", shipped it, and got nothing — the ADR-0078 shape arriving through a location vocabulary rather than through a missing key. It was retired rather than implemented because the demand evidence is empty: no user has asked for command-palette actions and the only two declarers were our own showcase corpus, so wiring the palette would have been capability expansion with no pull. This is an enum VALUE, not a key, so — as with crypto.hash and the two aggregate functions above — there is no retiredKey() tombstone: the enum error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed". The conversion strips the value from action.locations and KEEPS the key even when the array empties, because on this surface locations: [] and an absent locations are different declarations: the empty array is the documented headless shape (callable over REST/MCP/AI, capability gate and audit trail intact), while an absent key means nobody placed the action — which is what packages/lint's action-no-placement warns about. An object-less action, whose only reason for declaring global_nav was that it has no row and no record header to render on, is therefore migrated to the declaration it always meant.
It also removes the three pass-through-only list-view display keys striped / bordered / virtualScroll (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). All three were graded live on reads that turned out to be forwarding copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the key onto the next node, and the chain ends at ObjectGrid, which never spells any of the three — so an author who wrote striped: true got a parse-clean no-op, the exact silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in effect; per the ruling, if objectui wants one of these as real behavior, that is an implementation card filed first, and the key stays retired pending it.
Finally it removes the 'pdf' member of view.exportOptions formats (maintainer ruling 2026-08-12). PDF export was declined platform-side as not planned, so the member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu with only a runtime console.warn, so exportOptions: ['xlsx', 'pdf'] type-checked, validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT form for exportOptions — { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }, exactly the key set the renderer reads, ending the state where no declaration was both type-legal and functional — with the legacy bare array still accepted and lifted to { formats: [...] } at parse, which is why the conversion strips only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so — as with crypto.hash above — there is no retiredKey() tombstone: the format enum's error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed", plus a union-level dispatch so the refusal is the top-level message in either authored form. The strip keeps an emptied formats array rather than deleting the declaration.
Mechanical (applied for you)
| Conversion | Surface | Change | Load window |
|---|---|---|---|
action-execute-to-target | action.execute | 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) | retired — migrate meta only |
field-conditionalRequired-to-requiredWhen | field.conditionalRequired | field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, folded into the canonical key so no reader picks its own precedence) | retired — migrate meta only |
agent-tools-to-skills | agent.tools | 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) | retired — migrate meta only |
sharing-rule-access-level-full-to-edit | sharingRule.accessLevel | 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) | live — protocol 17 loader accepts the old shape |
flow-node-crud-object-alias | flow.node.config.objectName | CRUD flow-node config key 'object' → 'objectName' (the last alias in the executors' readAliasedConfig shim graduates into this layer, and the shim is deleted) | live — protocol 17 loader accepts the old shape |
flow-node-notify-config-aliases | flow.node.notify.config | 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) | live — protocol 17 loader accepts the old shape |
flow-node-wait-event-config-lift | flow.node.wait.waitEventConfig | 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) | live — protocol 17 loader accepts the old shape |
flow-node-connector-config-lift | flow.node.connector_action.connectorConfig | 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) | live — protocol 17 loader accepts the old shape |
flow-node-map-flow-alias | flow.node.map.config.flowName | map flow-node config key 'flow' → 'flowName' (an undeclared spelling the executor accepted through a bare fallback; it graduates into this layer) | live — protocol 17 loader accepts the old shape |
flow-node-subflow-flow-alias | flow.node.subflow.config.flowName | 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) | live — protocol 17 loader accepts the old shape |
flow-node-script-config-aliases | flow.node.script.config | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (executor ?? fallbacks, graduated into this layer) | live — protocol 17 loader accepts the old shape |
permission-rls-priority-removed | permission.rowLevelSecurity.priority | 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) | retired — migrate meta only |
tool-inert-authoring-keys-removed | tool.category / tool.permissions / tool.active / tool.builtIn | 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) | retired — migrate meta only |
app-dead-authoring-keys-removed | app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order | 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) | retired — migrate meta only |
app-area-fail-open-gates-removed | app.areas.visible / app.areas.requiredPermissions | 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) | retired — migrate meta only |
action-inert-keys-removed | action.shortcut / action.bulkEnabled | 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) | retired — migrate meta only |
flow-inert-keys-removed | flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId | 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) | retired — migrate meta only |
view-inert-keys-removed | view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria | 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) | retired — migrate meta only |
view-list-passthrough-keys-removed | view.list.striped / view.list.bordered / view.list.virtualScroll | 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) | retired — migrate meta only |
view-export-options-pdf-removed | view.list.exportOptions / view.listViews.*.exportOptions | 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) | retired — migrate meta only |
dashboard-inert-keys-removed | dashboard.aria / dashboard.performance / dashboard.widgets[].performance | dashboard keys 'aria'/'performance' and widget 'performance' removed (inert, removed under ADR-0049 enforce-or-remove: no renderer applied any of them) | retired — migrate meta only |
dashboard-widget-responsive-removed | dashboard.widgets[].responsive | 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) | retired — migrate meta only |
dashboard-widget-action-aria-removed | dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria | 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) | retired — migrate meta only |
dashboard-widget-compareto-converged | dashboard.widgets[].compareTo | 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) | retired — migrate meta only |
agent-knowledge-removed | agent.knowledge | agent key 'knowledge' removed (inert, removed under ADR-0049 enforce-or-remove: declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level) | retired — migrate meta only |
skill-trigger-phrases-removed | skill.triggerPhrases | 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) | retired — migrate meta only |
stack-api-require-auth-removed | stack.api.requireAuth | 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 | retired — migrate meta only |
flow-node-wait-timeout-keys-removed | flow.node.waitEventConfig | 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 | retired — migrate meta only |
datasource-read-replicas-removed | datasource.readReplicas | 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) | retired — migrate meta only |
datasource-capabilities-removed | datasource.capabilities | datasource key 'capabilities' removed (eleven flags no code read; pushdown comes from the driver's own supports.*, and readOnly never made anything read-only) | retired — migrate meta only |
datasource-inert-blocks-removed | datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission | 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) | retired — migrate meta only |
mapping-inert-keys-removed | mapping.extractQuery / mapping.errorPolicy / mapping.batchSize | 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) | retired — migrate meta only |
book-translations-removed | book.translations / book.groups.translations | 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 | retired — migrate meta only |
job-id-removed | job.id | 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) | retired — migrate meta only |
translation-validation-messages-removed | translation.validationMessages | 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) | retired — migrate meta only |
datasource-config-driver-key-aliases | datasource.config | 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) | retired — migrate meta only |
datasource-driver-mongo-to-mongodb | datasource.driver | datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used, so the id that selects a driver and the id that selects its config contract are one string with no mapping between them | live — protocol 17 loader accepts the old shape |
flow-node-script-branch-keys-removed | flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script | 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 | retired — migrate meta only |
retry-policy-converged | flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier | 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 | live — protocol 17 loader accepts the old shape |
object-managed-by-system-to-system-data | object.managedBy | 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) | retired — migrate meta only |
object-enable-trash-mru-removed | object.enable.trash / object.enable.mru | 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) | retired — migrate meta only |
hook-body-crypto-hash-removed | hook.body.capabilities / action.body.capabilities | 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) | retired — migrate meta only |
dataset-measure-array-string-agg-removed | dataset.measures[].aggregate | 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) | retired — migrate meta only |
connector-rate-limit-config-removed | connector.rateLimitConfig | 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) | retired — migrate meta only |
field-mapping-transform-removed | connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform | 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) | retired — migrate meta only |
theme-inert-token-scales-removed | theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex | 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) | retired — migrate meta only |
page-header-subtitle-alias | page.component.page-header.description | 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) | live — protocol 17 loader accepts the old shape |
object-index-type-partial-removed | object.indexes[].type / object.indexes[].partial | 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) | retired — migrate meta only |
record-picker-display-field-to-label-field | page.component.element:record_picker.displayField | 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) | retired — migrate meta only |
record-picker-inert-keys-removed | page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple | record-picker component props 'searchFields'/'multiple' removed (the control is a plain single-select with no search box; neither key had a reader) | retired — migrate meta only |
page-card-body-to-children | page.component.page:card.body | page:card component prop 'body' → 'children' (one composition key across every container; the card renderer already reads both) | retired — migrate meta only |
inline-action-api-params-to-body-extra | page.component.element:button.action.params | 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) | live — protocol 17 loader accepts the old shape |
page-tabs-type-to-tab-style | page.component.page:tabs.type | 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) | retired — migrate meta only |
page-structure-inert-keys-removed | page.component.page:header.icon / page.component.page:card.actions | 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) | retired — migrate meta only |
record-details-layout-removed | page.component.record:details.layout | record:details component prop 'layout' removed (the declared auto | custom modes were never implemented; the renderer branches only on inline |
app-hidden-to-unpublished | app.hidden | 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) | retired — migrate meta only |
action-global-nav-location-removed | action.locations[] | 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: []) | retired — migrate meta only |
Semantic (delegated to you, with acceptance criteria)
action-descriptor-is-async-retired—ActionDescriptor.isAsync (the descriptor an executor publishes viaregisterNodeExecutor/defineActionDescriptor)→ nothing to re-declare — delete the key. Suspension isexecute()RETURNINGsuspend: true, and permission to suspend issupportsPause: trueon the same descriptor (with theresumeAuthorityits pauses need)- Why not automatic: ADR-0049 enforce-or-remove.
isAsyncdeclared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (taken when the key was filed for retirement, and re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capabilitysupportsPausestates, and the two diverged in exactly the way a duplicated declaration does:screendeclared both,mapandwaitdeclaredisAsyncalongsidesupportsPause, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling —AutomationEnginenow refuses a suspension whose type does not declaresupportsPause: true— so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite andos migrate metacannot reach it. The schema tombstones it viaretiredKey()and descriptor authors delete the key themselves; that rejection (atscerror at the authoring site, and a parse error insidedefineActionDescriptor) is the channel a third-party plugin author actually meets. TheEnhancedApiError.fieldErrorsdisposition, one layer down. - Done when: No descriptor declares
isAsync— not the five that shipped it (screen,map,wait,approval,approval_revise), not a plugin's. Every node type that returnssuspend: truefromexecute()declaressupportsPause: trueon its descriptor together with aresumeAuthority, and its runs still pause and resume as before: the behaviour never depended onisAsync, so deleting the key changes no run. AuthoringisAsyncfailstscat the descriptor literal and failsdefineActionDescriptor()at runtime with the prescription, instead of parsing clean and being stripped.
- Why not automatic: ADR-0049 enforce-or-remove.
action-descriptor-resume-authority-default-flip—automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)→ an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning- Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's
rest-requireauth-default-flip, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The generic resume route's authorization gate keys on the SUSPENDED NODE, andActionDescriptor.resumeAuthorityused to default to'any', so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to'service'when absent: an unclaimed pause is refused on the generic route withPERMISSION_DENIED/ 403 until its descriptor states who may continue it. The revise-window incident decided the direction — ADR-0044 pointed an approval's revise edge at a genericwait,waitis legitimately'any', and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing'any'walks past a decision nothing recorded and is silent, while guessing'service'returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the dispositiondata-driver-find-stream-retired,storage-service-list-retiredandactor-user-roles-to-positionsalready carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, andcheck:resume-authority-declaredfor executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, and the 2026-07-28 resume-seam addendum to ADR-0019 (approval as a flow node). - Done when: Every action descriptor your plugin registers for a node type that can suspend declares
resumeAuthority. Booting the stack logs nodeclares supportsPause but never declares resumeAuthoritywarning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared'any', and answers 403 (PERMISSION_DENIED) for the ones you declared'service', which continue through your own service API instead. ⚠️supportsPauseis no longer the declaration nothing enforced: an executor whoseexecute()returnssuspend: truewhile leavingsupportsPausefalse is still warned about by neither warning channel, butAutomationEngine.refuseUndeclaredSuspensionnow refuses that suspension at the one seam every suspension passes through — a guard-class failure nofaultedge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route.
- Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's
action-session-roles-to-positions—ui.actionSession.roles→ ui.actionSession.positions (an action body readsctx.session.positions)- Why not automatic: The MIRROR-IMAGE sibling of
actor-user-roles-to-positions, and the reason both are in this step: the hookctx.sessioncarriedrolesdeclared-and-never-produced (removed outright), while the ACTION body'sctx.sessioncarries it produced-and-really-populated.buildActionSession()(packages/runtime/src/action-execution.ts) copiesExecutionContext.positionsinto a key spelledroles— the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. The maintainer ruled contract-first on 2026-08-06 ("C skeleton + A semantics": declare the shape as it stands first, then rename on the typed face): phase 1 declared the previously undeclared shape asActionSessionSchema, and phase 2 renames the key.positionsis now the canonical key on that schema androlesa deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after whichrolesis removed on the path the v16 session-alias removal already walked (the hook session'stenantIdalias: deprecated first, removed in the next major). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an actionctx.sessionis constructed per dispatch and never persisted, so nosys_metadatarow, example or template can carry the key — theopenApi31/activationEvents/hook-context-session-roles-retiredshape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whoseScriptContext.sessionis stillunknown. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegatedcurrent_user.rolesto the author at step 13 (cel-current-user-roles-to-positions) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. AretiredKey()REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel:spec-changes.jsonand the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087. - Done when: No action body reads
ctx.session.roles; every such read isctx.session.positionsand observes the same array (the rename is a rename — the VALUE isExecutionContext.positionson both sides, which the runtime pinaction-session-shape-contract.test.tsasserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that wasroles.includes('admin')as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed topositions.includes('admin')— renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it,rolesis absent and a body still reading it seesundefined— which is why the read must be moved inside the window rather than at its close.
- Why not automatic: The MIRROR-IMAGE sibling of
actor-user-roles-to-positions—action body / AI route: ctx.user.roles (req.user.roles)→ ctx.user.positions (an AI route handler readsreq.user.positions) — the same array, under the one spelling ADR-0090 D3 sanctions- Why not automatic: The THIRD face of the ADR-0090
roles→positionsrename, and the only one whose surface the spec never declared.ActorUser(packages/runtime/src/security/actor-user.ts) is the ONE producer of theuserenvelope handed to an action body asctx.userand to an AI route handler asreq.user; it declaredpositionsandrolesside by side and filled them from a SINGLE assignment (roles: core.positions), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, on the finding that this alias had no closing date): no deprecation window, no dual-emit, the alias simply gone in 17. ⚠️ Do not read this entry across to its siblingaction-session-roles-to-positions:action-session-roles-to-positionsgovernsctx.session, a DIFFERENT object reached through the samectx, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules —ctx.user.rolesis absent in 17 whilectx.session.rolesstill answers for the length of its window. What makes this entry different in KIND from both session-side siblings:ctx.userhas no spec schema and never had one. It is a runtime TS interface, so unlikeHookContext.session.roles(tombstoned on a deliberately non-strictHookContextSchemaonce it had no producer and no consumer left) and unlikeActionSessionSchema(declared contract-first, as it stood, as the first stage of the session rename, precisely so its key could be renamed), there is no schema key here to tombstone and noretiredKey()prescription that could reach anybody — nothing ever ran anActorUserthrough a.parse(), so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist —spec-changes.jsonand the generated upgrade guide are the ONLY way such a reader learns of the rename. It is thefindStream(no caller) /IStorageService.list(no consumer) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED inpackages/spec/src/contracts, this one only inpackages/runtime. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — anActorUseris constructed per dispatch and never persisted, so nosys_metadatarow, example or template can carry the key (theopenApi31/activationEvents/hook-context-session-roles-retiredshape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegatedcurrent_user.rolesto the author at step 13 (cel-current-user-roles-to-positions) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face againstorigin/main— repo-wideuser.roleswas 4 hits, all of them in the pins the removal flipped; the fourActorUserconstruction sites build server-side envelopes that never enter a response body; objectui's.rolesreads belong to two unrelated producers (the better-auth session, and the/auth/me/permissionspayload). Thecloudrepo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087. - Done when: No action body reads
ctx.user.rolesand no AI route handler readsreq.user.roles; every such read is.positionsand observes the SAME array — the value wasExecutionContext.positionson both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that wasroles.includes('admin')as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed topositions.includes('admin')— renaming that read migrates the defect rather than the code. Unlikectx.sessionthere is NO window to migrate inside: in 17 the key is already absent, so a typed body failstscat the read while an untyped or sandboxed one silently seesundefined— move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence ('roles' in ctx.user === false) rather than byundefined, which cannot tell a removed key from one left behind holding nothing — the runtime pinaction-ctx-user-shape.test.tsasserts both halves that way.
- Why not automatic: The THIRD face of the ADR-0090
aggregation-node-distinct-retired—data.query.aggregations[].distinct→ thecount_distinctaggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered toCOUNT(DISTINCT field)on both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it declared and retiredarray_agg/string_agg.SUM(DISTINCT …)/AVG(DISTINCT …)get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data- Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the
QueryASTmembers no executor runs, the one that dispositioned every otherdata.query.*member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (objectql/src/in-memory-aggregation.ts) deduplicated the values before applying the function, whileSqlDriver.aggregate, the TursoRemoteTransport.aggregate,driver-mongodb'sbuildAggregationStage,driver-memory'scomputeAggregateand service-analytics'AGGREGATE_SQLall ignored the key. So{ function: 'sum', field: 'amount', distinct: true }answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the divergences closed earlier on the same axis (an aggregate function name the remote Turso face compiled and the local face refused, and an unsupported function thrown as a bare error with no code on both SQL faces), the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback:sumandavgonly —countreturned from its own branch before reaching the dedupe,count_distinctfed the values into a Set (dedupe-then-Set is Set), and dedupe does not movemin/max. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09):count_distinctalready covers the only spelling anyone has measured demand for, and loweringSUM(DISTINCT …)across five faces — two of them, driver-memory and driver-mongodb, then under the maintainer's 2026-08-05 investment freeze, a freeze lifted 2026-08-11, after this ruling — buys a shape that is near-universally a modelling mistake. A REQUEST surface —QueryASTis the client SDK builder's output and thePOST /data/:object/querybody, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the disposition that sweep gavejoins/cursor/distinct/windowFunctions, applied verbatim one level down. ADR-0049. - Done when: No caller sends
distinctinside anaggregations[]entry, on the wire or through the SDK; a deduplicated count is written as{ function: 'count_distinct', field }and reads the same number on every backend. A query still carrying the key fails to parse with the removal prescription — including throughEngineAggregateOptionsSchema, which reusesAggregationNodeSchemaby reference — andPOST /api/v1/data/:object/queryanswers400 VALIDATION_FAILEDwith afields[]entry ataggregations.<i>.distinctinstead of serving a number. Authoring it is atscerror at the call site. ⚠️ The observable NUMBERS change on exactly one path and that is the point of the change: asum/avgthat used to be deduplicated by the in-memory fallback now answers what every SQL face has always answered for the same query. Verify against the SQL answer, not against the pre-upgrade fallback answer — the two disagreed, which is why the key is gone.
- Why not automatic: A DIVERGENCE, not an inert declaration — which is why it outlived the sweep of the
analytics-query-request-envelope-retired—api.analyticsQueryRequest.query→ bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)- Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its
wherefilter at the door), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. - Done when: Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.
- Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (the fallback that answered /analytics/query when no analytics service was installed, and dropped the caller's identity and its
analytics-query-request-format-retired—api.analyticsQueryRequest.format→ (removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)- Why not automatic: The
formatkey was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite. - Done when: No /analytics/query or /analytics/sql call sends
format; exports go through the export surface.
- Why not automatic: The
api-runtime-create-withdrawn—PUT /api/v1/meta/api/{name} (runtime-authoredapiendpoints, draft and active alike)→ Declare the endpoint as a stack artifact (**/*.api.ts, ordefineStack({ apis })) and ship it throughpublishPackage- Why not automatic: The
apiregistry entry declaredallowRuntimeCreate: trueand the runtime never honoured it. Measured on a real showcase boot:PUT /api/v1/meta/api/e8_backdooranswered 200 with{"success":true,…,"message":"Saved …"}, and the declared route then answered 404 forever — with NO[EndpointMatcher] … EXCLUDEDline, because the endpoint was never in the index to be excluded from. The serving criterion belongs toIMetadataService.matchEndpoint->EndpointMatcher->MetadataManager.listForIndex('api'), which reads the manager's registry plus its registered loaders (["filesystem","memory"]on dev/serve); a runtime write lands insys_metadata, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers "Saved" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher readsys_metadatare-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero.api.*artifacts author them at runtime; showcase uses the artifact route, and its declared endpoints serve live). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key.allowRuntimeCreateis a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a**/*.api.tsfile valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same dispositionBatchOptions.validateOnlytakes. ConsequentlygateApiDraftsForPublishis retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed noapidraft can exist for it to judge. Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotesapisto a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. The same refusal closes the direct-active write too, which had been a third path past the endpoint namespace and duplicate-path gates. ADR-0049 / ADR-0121. - Done when: No caller creates or updates an
apiitem through the runtime metadata API.PUT /api/v1/meta/api/{name}answers 403 withcode: "NOT_CREATABLE"and a body naming both flags (allowRuntimeCreate=false, allowOrgOverride=false) and the prescriptionDeclare it in source (**/*.api.ts) and redeploy— in?mode=draftas well as direct-active, because the gate runs before the draft/publish branch and does not readmode. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaringapis:still compiles, still passesvalidateApiEndpointDeclarationsat publish (publishPackage) and at load (buildEndpointIndex), and its endpoints still SERVE — that route was always the only one that served. An operator who genuinely needs the runtime door back on one deployment setsOS_METADATA_WRITABLE=api, the same single escape hatchjob/agent/capabilityuse; note that this unlocks the WRITE only, and the endpoint still will not be served, which is why it is a diagnostic and not a workaround. Anyapirows already sitting insys_metadatafrom before this change were never served either; they can be deleted (deleteMetaItemis deliberately not gated by this refusal, so repair stays possible).
- Why not automatic: The
apimethod-enum-shrink—data.object.enable.apiMethods (the eight legacy non-primitive values)→ the six primitives only —get/list/create/update/delete/bulk: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six- Why not automatic: The authored
enable.apiMethodsenum is now exactly the six primitives. The eight legacy values —upsert,aggregate,history,search,restore,purge,import,export— are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename:upsert→create+update;import→create+update;export,aggregateandsearch→list;history→get; andrestore/purgemap to NOTHING — they never derived, becauseenable.trashwas retired with the other deadenable.*flags in the 11.0 ADR-0049 removal of dead author-facing properties, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naminghistorywas granting read of one record's audit trail; rewritten togetit grants ordinary record reads, and an allowlist namingsearchbecomes a grant of fulllist. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape:node scripts/codemod/apimethods-legacy-to-primitives.mjsscans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the enum shrink (phase 2 of the programme that made UI action buttons agree with theapiMethodsallowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087. - Done when: No authored
enable.apiMethodsarray names a legacy value;objectstack validatepasses. Run the reporter codemod first and read its widening flags before applying anything — ⚠️ the migration is only correct if each widened grant was INTENDED. For every object wherehistorybecamegetorsearchbecamelist, confirm the broader operation is one the API should genuinely expose; where it is not, the answer is not a different value in this enum but a permission set that withholds the operation. Where the six primitives are all present, prefer deleting the key: that is equivalent to default-open and it tracks future primitives, whereas a hand-listed six silently stops granting anything added later.restore/purgeare deleted with no replacement — if trash-like behaviour was being relied on, that capability left in the 11.0 dead-property removal and this entry is not where it returns.
- Why not automatic: The authored
approval-escalation-enabled-default-flip—automation.ApprovalEscalation.enabled — an OMITTED value inside an approval node's escalation block→ nothing, for the common intent (escalate on timeout): an escalation block carrying timeoutHours is live by default. To declare an SLA OFF while keeping its configuration, write enabled: false explicitly — which is now the spelling the escalation sweep actually reads- Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's
import-run-automations-declared-default-corrected: the schema promisedenableddefaults tofalse(SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positivetimeoutHoursescalated, and withaction: 'auto_approve'that silently approved requests their author had declared off the clock. The flip moves the default totrueand, in the same change, the sweep starts honouring an explicitenabled: false. The feature-level switch is whether anescalationblock exists at all; within a block carryingtimeoutHours, escalation is on unless explicitly turned off. Deployed metadata that OMITSenableddoes not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializestrue). Stored request snapshots written before the flip carry a MATERIALIZEDenabled: false(the approval-node executor parses config through the old schema before snapshotting), so the sweep keeps a read-side legacy window keyed on the snapshot'screated_at: pre-flip snapshots keep escalating exactly as they do today, and the window retires itself as those pending requests drain. What DOES change is that an explicitenabled: falsefinally binds — a flow that authored it (e.g. the console toggle switched off after a timeout was set) stops escalating on requests opened after the upgrade, which is the declared intent being honoured. - Done when: A flow whose approval node omits
enabledinsideescalationstill escalates on timeout (no metadata edit needed). A flow that writesenabled: falsestops escalating for newly opened requests — verify one such request stays pending past itstimeoutHourswith noescalateaudit row and no auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade behaviour (they escalate) regardless of the storedenabledbit. Clients that parse metadata through the published JSON Schema now materializeenabled: truewhere they materializedfalse; a client that needs the SLA off must write it explicitly.
- Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real (maintainer ruling 2026-08-27, which moved the declared default to what the sweep had always done) — the same category as protocol 17's
audit-log-action-enum-retired—sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view→ nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinarycreate/updaterows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. Forexportthere is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filteringsys_audit_logon either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise- Why not automatic: Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (
login/logouton the auth session hooks,config_changefrom the settings service) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating everysys_audit_logwriter in the repo — there are exactly two: plugin-audits generic hook writer, whoseactionFormaps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auths admin user-import. Neither has ever emittedexportorpermission_change. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two wayshook-body-crypto-hash-removed,dataset-measure-array-string-agg-removedandaction-global-nav-location-removedalready record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite:sys_audit_logis a platform-owned, append-only object whose every field isreadonly: true. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a storedsys_metadatarow; this surface is neither, so the disposition is the oneBatchOptions.validateOnlyand the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (validateRecordskipsreadonlyfields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087. - Done when: No consumer filters
sys_audit_logonaction = "export"oraction = "permission_change"expecting rows: both were empty everywhere before this change, so a query that returned data has not been identified and a query that returned nothing behaves identically. Concretely, check three places. (1) Saved queries, dashboards and reports oversys_audit_log: a filter naming either value should be deleted, not re-pointed — for permission auditing, filter the permission objectsowncreate/updaterows byobject_nameinstead. (2) Any code branching on the action string (a badge map, a label switch, anif (row.action === ...)): the arms for these two values are now unreachable and should go, and aswitchwith an exhaustiveness check over the enum type will now fail to compile if they stay — that compile error is the enforced channel for TypeScript consumers. (3) Custom objects or plugins insertingsys_audit_log` rows with either value: this is the only case that needs a real decision, because the write will NOT be refused (readonly fields are not validated) — it will simply be a row whose action the object no longer declares. Pick a declared value or open an issue for the action you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is append-only and stays exactly as written.
- Why not automatic: Maintainer ruling 2026-08-12 on the audit log's writerless actions, the retirement half of a two-half verdict: the cheap writers get built (
audit-log-action-restore-retired—sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles→ nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle tocreate/update/deleteonly. A consumer filteringsys_audit_logon this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (an undelete / purge permission lifecycle and a soft-delete recycle bin, neither built yet), not this enum row- Why not automatic: The same maintainer ruling as
audit-log-action-enum-retired, carried to the one value that ruling's own survey did not name (triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎.restoreis the least ambiguous member of the family: the record-level writer could not have produced it even by accident, becauseactionFor()in audit-writers.ts is typed'create' | 'update' | 'delete' | nulland its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: thewrites_onlylist view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (the shape a credential-storage audit had to settle by re-measuring two "hashed at rest" comments) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite —sys_audit_logis a platform-owned, append-only object whose every field isreadonly: true, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected: the undelete / purge lifecycle and the recycle bin are both held open, not declined. If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (validateRecordskipsreadonlyfields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087. - Done when: No consumer filters
sys_audit_logonaction = "restore"expecting rows: it was empty on every deployment before this change and behaves identically after it. Concretely, check three places. (1) Saved queries, dashboards and reports oversys_audit_log: a filter namingrestoreshould be deleted, not re-pointed — there is no action that carries the meaning, because the platform records no restore event. (2) Any code branching on the action string (a badge map, a label switch, an option list in an audit-log filter UI): therestorearm is unreachable and should go, and aswitchwith an exhaustiveness check over the enum type will now fail to compile if it stays — that compile error is the enforced channel for TypeScript consumers. An option in a FILTER dropdown is the user-visible half and matters most: it offers an operator a choice that returns nothing. (3) Custom objects or plugins insertingsys_audit_logrows with this value: the write will NOT be refused (readonly fields are not validated), so it silently becomes a row whose action the object no longer declares. Pick a declared value, or open an issue for the action you actually need. ⚠️ Do NOT migrate or delete existing rows: audit history is append-only and stays exactly as written.
- Why not automatic: The same maintainer ruling as
auth-config-unadvertised-reserved-features—api.authConfig.features.passkeys / api.authConfig.features.magicLink→ (removed — no replacement flag; the capabilities are not advertised)- Why not automatic: Both flags were served by
GET /api/v1/auth/configfrom introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer settingplugins.passkeys/plugins.magicLinkflipped a switch with no observable effect (ADR-0049 enforce-or-remove; the maintainer ruling of 2026-08-11 chose remove over keep-as-reserved, so that a deployer cannot flip a flag that does nothing anywhere). The two are not equally empty: nothing at all is wired behindpasskeys, whereasmagicLink's better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists anAuthFeaturesConfig— so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (flag-gated passkey and magic-link entry points, which objectui defers until the maintainer schedules them). ADR-0049. - Done when: No client reads
features.passkeysorfeatures.magicLinkoff/api/v1/auth/config; a client that gated UI on either now treats the capability as absent rather than readingundefinedas false by accident, and constructing anAuthFeaturesConfigwith either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working:plugins.magicLinkstill mounts/api/v1/auth/magic-link/sendand/magic-link/verify, which a custom UI may call directly.
- Why not automatic: Both flags were served by
authoring-schemas-strict-unknown-keys—the protocol-17 authoring schemas closed against undeclared keys by the unknown-key strictness wave —automation/(flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function),security/(permission sets, RLS policies, sharing rules) andidentity/position,ui/(responsive, theme, chart,AriaProps, fifteenviewsub-blocks,ViewItem,userFilters) — plus theviewwrite-path identity precondition one level above them→ declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. Aviewbody must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (VIEW_WRITE_PATH_IDENTITY_KEYS)- Why not automatic: zod's default
.stripdiscarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (inputSchema.optionalis the opposite polarity ofrequired;errorHandling.maxAttemptscounts the first attempt wheremaxRetriescounts the ones after it; aresponsiveStylesbucket written onresponsiveis a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance;aria.liveis real on exactly one renderer andariaLabelledByhas nothing to rename to;finallyontry_catchandcontexton a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — theviewunion had an arm that both stripped and required nothing, so it matched every object andsaveMetaItempersisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the maintainer's 2026-08-12 ruling that the wave is registered one entry per major, not one per batch, mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesetsunknown-key-strictness-tier-a,-step2,-automation-batch11,-ui-batch13,-ui-batch15,-ui-batch16,strict-automation-control-flow-state-machine,view-subblock-strictness-batch18,rare-jars-shave,user-filters-allow-add-tab-promote-and-closeandview-union-identity-precondition, each carrying its own FROM → TO table inCHANGELOG.md. One batch promoted a key before closing its block:userFilters.allowAddTabwas already read by objectui, so the maintainer's 2026-08-04 ruling declared it in the spec rather than let a correct-looking refusal tell authors to delete a working capability. The entry was registered in the backfill of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0049 / ADR-0078 / ADR-0087. - Done when:
objectstack validatepasses with no unknown-key parse errors on any authoring surface — the sweep is "fix until nothing raises", and every rejection carries its own fix. ⚠️ Parsing clean is the weaker half on three of these faces, because the key was being dropped rather than refused and a config that silently did nothing looked exactly like one that worked: re-check that responsive/theme/chart styling actually renders as authored, that everyariablock still names the element it was written for, and that each state machine and loop config still carries the transitions and caps you declared. For storedviewbodies,GET /api/v1/meta/diagnostics?type=viewlists every overlay the identity precondition now rejects, one row per view with the reason; each is fixed by giving the body a real view shape or deleting an overlay that was never a view.
- Why not automatic: zod's default
batch-options-validate-only-retired—api.batchOptions.validateOnly→ (removed — no dry-run today; open an issue to design a no-commit batch preview)- Why not automatic: The
validateOnlykey promised a dry-run ("validate records without persisting") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it. - Done when: No /batch, /updateMany or /deleteMany call sends
options.validateOnly; a request that includes it answers 400 VALIDATION_FAILED with the retirement prescription.
- Why not automatic: The
batch-row-result-schema-shape—api.batchOperationResult — the per-rowresultsentries of BatchUpdateResponse (POST /data/:object/batch,/updateMany,/deleteMany)→errors: ApiError[](waserror: string— readrow.errors?.[0]?.message, branch onrow.errors?.[0]?.code),data(wasrecord), andindex(new — the row's position in the request array)- Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them:
BatchOperationResultSchema, the client SDK's exportedBatchOperationResulttype and the reference docs all saiderrors: ApiError[]/data/index, while the wire carriederror: string/recordand never sentindexat all. A TypeScript consumer written against the published type compiled, validated and readundefinedat runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119 rollback marking, which the fix makingdeleteManyDataandupdateManyDatahonouratomiccarried to those two endpoints, is structured in the same move: theROLLED_BACK:/NOT_ATTEMPTED:message-string prefixes become registeredApiError.codevalues (message keeps the human-readable cause and causal row index), so "attempted and undone" vs "never ran" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. Ruled 2026-08-03: the implementation moves to the schema's shape as a hard cut in the 17 major, with no dual-emit transition. - Done when: No consumer reads
row.errororrow.recordon a batch result row; failures are read fromrow.errors(message viaerrors[0].message, rollback state viaerrors[0].code— ROLLED_BACK / NOT_ATTEMPTED), records fromrow.data, and rows correlate to the request viarow.index. Every row the three endpoints emit parses underBatchOperationResultSchemawith those keys present.
- Why not automatic: The rows the three bulk-write endpoints emitted had drifted from the schema that declared them:
client-delete-result-success—client.DeleteDataResult.deleted (the return ofclient.data.delete())→success—r.deleted→r.success. Same call, same wire body, declared name- Why not automatic:
DeleteDataResultcarried the commentSpec: DeleteDataResponseSchemaabove a declaration that contradicted it: the interface declareddeleted: booleanwhileDeleteDataResponseSchemadeclares{ object, id, success }.deletedhas never been declared by any schema and no server path has ever returned it on/data/:object/:id. Both delete surfaces —client.data.delete()and the project-scopedclient.project(id).data.delete()— are pureunwrapResponse/_unwrappassthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling.if (r.deleted)compiled, readundefinedat runtime, and the branch was never taken;if (r.success)was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already readingundefined, on every deployment and not just some, because the protocol path has always answeredsuccess. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched onr.deletedhas been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not writer.success ?? r.deleted: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same rule already moved the runtime's ObjectQL fallback from answeringdeleted: trueto the declaredsuccess, on the producer side). No deprecateddeleted?: booleantransition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the stock reconciliation of the v17 train's breaking changesets, which had never been compared against the ledger. ADR-0087. - Done when: No code reads
.deletedoff aclient.data.delete()/client.project(id).data.delete()result;tscnames every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: everyif (r.deleted)has been false since it was written, so re-read what each of those branches was supposed to do. Post-delete cleanup, cache invalidation, audit writes and UI refreshes guarded that way have never run, and switching tor.successturns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Any test that passed while asserting ondeletedwas asserting onundefinedand needs rewriting, not renaming.
- Why not automatic:
connector-inline-authentication-publish-refused—connector.authentication on AUTHORED entries (defineStackconnectors:,PUT /meta/connector/:name) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too→ a catalog descriptor dropsauthentication(or sets{ type: "none" }) and documents the auth scheme indescription; a dispatchable instance declaresproviderand references its credential withauth: { type, credentialRef }(ADR-0097 §3). RuntimeregisterConnectorcalls are unaffected — the runtime shape still carries resolved secrets inline.- Why not automatic: A published connector row lands whole in
sys_metadata, so an inlinetoken/key/password/clientSecretis cleartext at rest, readable through the data API (the class a credential-persistence survey measured: any authored artefact whose schema permits an inline credential lands it there). No mechanical rewrite exists: whether the entry should become anonedescriptor or a provider-bound instance with acredentialRef— and which secret store receives the credential — is a judgment about the connector, not a rename. - Done when: Every authored connector entry parses through
DeclarativeConnectorEntrySchema; no authored entry carries a non-noneauthentication; formerly inline credentials are reachable throughcredentialRefresolution and the connector still materializes.
- Why not automatic: A published connector row lands whole in
dashboard-widget-compareto-offset—dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')→ compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's ownfilter- Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape,
{ kind, dimension? }, with nooffsetconcept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape —{ offset }was forwarded verbatim into that contract and threwcompareTo requires a timeDimension "undefined", taking the widget down; the arm ever only ran on the legacy inline chart path (measured when all three declared arms were found dead on the dataset path: two silently dropped, this one throwing). The conversion rewrites{ offset: '1y' }, which ISpreviousYearby definition. Every other duration has NO faithful target:previousPeriodshifts by the length of whatever window the widget's filter resolves to, which equals7donly when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. - Done when: No dashboard widget declares
compareTo.offset. Each former offset comparison states its window on the widget'sfilterand compares withcompareTo: { kind: 'previousPeriod' }(or'previousYear'), anddimensionis named wherever the selection dates more than one time dimension.objectstack validatepasses, and each affected widget renders a<measure>__comparecolumn over the window its author intended.
- Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape,
data-driver-find-stream-retired—contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream→ find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)- Why not automatic:
findStreamwas a REQUIRED contract method documented as "optimized for large datasets to avoid memory overflow", and in two of its three implementations it delivered the opposite:SqlDriverandInMemoryDriverboth awaitedfind()for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (MongoDBDriver._findStream) did walk a cursor, but it was the one read path in that driver never routed throughbuildFindOptions, so it hardcodedprojection: { _id: 0 }and silently discardedquery.fields. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go throughfind(). The ~20 driver test doubles that existed only to satisfy a required method almost all threwnot implemented, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object throughDriverInterfaceSchema.parse(), so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078. - Done when: No code calls
driver.findStream(...); large reads page throughfind()withlimit/offset(which guarantees a total order across the whole walk) or go through the export surface. Drivers and test doubles no longer implement the method — one left behind still compiles and is simply never reached, so removing it is cleanup rather than a break, while a CALLER of it no longer type-checks.
- Why not automatic:
data-driver-query-omit-object—contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain→DriverQuery(Omit<QueryAST, "object">): delete the redundantobject:key from the query literal at the call site — the object name is already the FIRST argument- Why not automatic: Every one of these methods takes the object name as its first argument, and then required a
QueryASTthat listsobjectas mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as{ ...query, object }so a smuggledquery.objectcannot override the resolved name, and the wire layer spends a named 400 (QUERY_OBJECT_MISMATCH) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only awherecould not name the type, wroteas any, and switched off checking forwhere/orderBy/fieldsalong with it — 20 such sites were measured in the downstream cloud codebase, and a$likethe type layer would have caught reached runtime there through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353'object' does not exist in type 'DriverQuery'), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding aQueryASTVALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaringquery: QueryASTkeeps compiling under parameter bivariance. What an implementation may no longer do is READquery.object— callers are now entitled to omit it. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. This change was that audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the sevenIDataDriverhits in the ledger are all prose inside other entries, and the only subject-level hit on this interface isdata-driver-find-stream-retired— a DIFFERENT member. Two later, smaller driver call-parameter changes both registered, and both cite this narrowing as background; the larger sibling they derive from never got its own entry. ADR-0087 (backfilled by that reconciliation). - Done when: No
IDataDrivercall site passes an inline literal carryingobject:—driver.find("account", { object: "account", where: … })becomesdriver.find("account", { where: … }).tscis the verify loop for typed callers and reports every remaining site by name; an untyped JS caller must be swept by hand, because nothing will report it. Any driver IMPLEMENTATION that readquery.objectis rewritten to use the object-name argument instead — that read now yieldsundefinedwhenever a caller exercises its new right to omit the key, and it fails at runtime rather than at compile time, so it is the one change on this surface a type check cannot find for you.
- Why not automatic: Every one of these methods takes the object name as its first argument, and then required a
data-engine-batch-retired—contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema→IObjectQLEngine.transaction(cb)for in-process multi-write atomicity; the metadata protocol'sbatchDatawithoptions.atomic: truefor a batch over one object;POST {basePath}/batchon the wire- Why not automatic:
batch?was declared onIDataEnginefor as long as that contract existed and was never implemented by any engine:ObjectQLhas nobatchmethod and there is no other engine in the tree. It also had no caller —DataEngineRequestwas imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment ("Batch Operations (Transactional)"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or whattransaction: falsewas supposed to mean — the questions a batch API exists to answer. Contrast its neighboursgetDefaultDriverName?/getDriverByName?, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema:DataEngineBatchRequestSchema.requestsnested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying abatchproperty, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 madetransactionreachable through the contract and D4 madebatchData'satomichonest, while the wire batch has always validated withCrossObjectBatchRequestSchema/BatchUpdateRequestSchemafromapi/batch.zod.ts— a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsedDataEngineBatchRequestSchema, so aretiredKey()prescription would have no one to reach; its threeauthorable-surface.jsonbaseline lines and itsjson-schema.manifest.jsonentry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078. - Done when: No code calls
engine.batch(...)and no type referencesDataEngineBatchRequest; in-process multi-write atomicity goes throughIObjectQLEngine.transaction(cb), a batch over one object throughbatchDatawithoptions.atomic: true, and a cross-object batch over the wire throughPOST {basePath}/batch. Because no engine implemented the member, an implementation left behind still compiles and is simply never reached; a CALLER of it no longer type-checks — and there were none.
- Why not automatic:
data-field-changed-event-retired—api.DataEventType 'data.field.changed'→ thedata.record.updatedevent, whose payload already carries the per-field detail:changes(the changed fields), plusbefore/after- Why not automatic:
data.field.changedwas declared inDataEventTypeand emitted by nothing — the engine'spublishDataEventsendsdata.record.{created,updated,deleted}and (since multi-record predicate writes were given events of their own)data.records.{updated,deleted}, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surroundingswitchstill compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary).DataEventSchemacould not have carried the semantics even if something had emitted it — the payload is record-shaped (recordId,changes,before,after) with nofield/oldValue/newValueslot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden ondata.record.updatedaschanges, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorableWebhookTriggerType, whose vocabulary was already trimmed to producers that exist) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rulefullretirementowd-full-alias-removedhit). The enforced channels are tsc, which fails any consumer still naming the value in aDataEventTypeposition, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way bulk writes were given theirs. ADR-0049 / ADR-0078. - Done when: No consumer subscribes to or switches on
data.field.changed; per-field change detail is read from adata.record.updatedevent'schangesmap (withbefore/afterfor the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability.
- Why not automatic:
datasource-config-inline-credential-refused—datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)→ the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted intosys_secret, handle stored atexternal.credentialsRef), or a directexternal.credentialsRefsecrets-store reference- Why not automatic: A datasource artefact is persisted whole into
sys_metadata, which is served back by the ordinary data API — an inline credential is cleartext at rest. The maintainer ruled on 2026-08-12 to close that per artefact: each schema that admitted an inline credential refuses it at publish and points at the secret mechanism the artefact already had, rather than a heuristic guard at thesys_metadatawrite. There is no mechanical rewrite: moving the value requires ENCRYPTING it into asys_secretrow through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead. - Done when: Every datasource parses with no
config.password/config.authTokenkey; each affected datasource carriesexternal.credentialsRef(or has its secret bound through the connection form) and still connects; no cleartext credential remains in any storedsys_metadatarow or authored source.
- Why not automatic: A datasource artefact is persisted whole into
datasource-config-placeholder-refused—connection-material string keys of the built-in driver configs — postgres/mysql/mongourl/host/database/username, postgresschema/applicationName, mongoauthSourceand theoptionspassthrough (judged deep), tursourl/syncUrl/encryptionKey, sqlite/sqlite-wasmfilename— values containing${…}placeholder syntax→ the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted intosys_secret, handle stored atexternal.credentialsRef), or a directexternal.credentialsRefreference. For environment-driven connections, the runtime environment itself:OS_DATABASE_URLand friends are translated into driver config by the boot hosts and never pass through the publish door- Why not automatic: A
${…}placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim insys_metadataand handed verbatim to the database client at connect (measured by the credential census while the inline-credential refusal was being built), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (the inline-credential refusal and the URL-userinfo refusal) had to warn "do NOT substitute a placeholder" around the broken escape. The maintainer ruled on 2026-08-13 for the second of two directions: refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target. - Done when: Every datasource parses with no
${…}span in any connection-material config value; environment-driven deployments carry their DSN in the runtime environment (OS_DATABASE_URLand friends) or bind secrets viaexternal.credentialsRef, and still connect; no unresolved placeholder remains in any storedsys_metadatarow or authored source.
- Why not automatic: A
datasource-config-url-userinfo-refused—datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (user:password@host)→ the same URL with its userinfo password removed (a bareuser@hoststays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted intosys_secret, handle stored atexternal.credentialsRef), or a directexternal.credentialsRefsecrets-store reference- Why not automatic: The inline-credential closure refused the credential KEYS, and building it measured that
config.urlstill accepted the identical secret one syntax over —postgresql://user:password@host/dblanded insys_metadatacleartext exactly asconfig.passworddid, and the key refusal itself steered authors there. The maintainer ruled on 2026-08-12 (Option A) to refuse the URL userinfo password at publish, through one value-level parse the driver schemas share. Runtime-environment DSNs (OS_DATABASE_URLand friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entrydatasource-config-inline-credential-refused: moving the value requires ENCRYPTING it into asys_secretrow through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a${…}placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (measured when the inline-credential refusal was built). - Done when: Every datasource parses with a credential-free
config.url/config.syncUrl(no userinfo password segment); each affected datasource carriesexternal.credentialsRef(or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any storedsys_metadatarow or authored source.
- Why not automatic: The inline-credential closure refused the credential KEYS, and building it measured that
declarative-apis-endpoints-live—stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)→ the same declarations, re-read as LIVE HTTP routes:pathmoved under/api/v1/apps/<manifest.namespace>/<subpath>, and every entry that declaresauthRequired: falsere-confirmed as an intentionally anonymous endpoint carryingrateLimit: { enabled: true, … }- Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared
path, no matcher existed, and every key —authRequiredincluded — parsed green and gated nothing (which is why the maintainer's 2026-08-04 ruling refused a non-emptyapis:outright until an executor existed). Protocol 17 ships that executor and narrows the refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So anapis:block written against an older major — or one restored from a source older than that refusal, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable isauthRequired. Its schema default istrue, so an omission is SAFE and needs no review; an EXPLICITauthRequired: falseis the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armedrateLimit(enabled: true— the key defaults tofalse, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them withApiEndpoint— the AUTHOR state — so that omittingauthRequiredcompiles:const e: ApiEndpoint = { name, path, method, type, target }is legal and is the safe shape this paragraph prescribes.ApiEndpointParsedis the POST-parse type (defaults materialized, ADR-0122), whereauthRequiredis required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value isfalseis the one thing this entry is trying to avoid. Hold a parse RESULT withApiEndpointParsed; write declarations asApiEndpoint. Grep everyapis:entry forauthRequired: falsebefore you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (/api/v1/apps/<namespace>/…), the namespace comes from an explicitmanifest.namespacewith no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every
apis:block, not just the ones that fail to publish. Concretely: (1) each declaredpathis/api/v1/apps/<your manifest.namespace>/<subpath>and the stack declares thatmanifest.namespaceexplicitly; (2) every entry declaringauthRequired: falseis one you INTEND to be reachable without a session, and each carriesrateLimit: { enabled: true, windowMs, maxRequests }— entries that were not intended to be anonymous have the key removed so the safe default (true) applies; (3)objectstack validatepasses, which also proves no endpoint declares a shape 17.x cannot execute (type: script/proxy, mappingtransform, anobject_operationmissingobjectParams,cacheTtlon a non-GET method,inputMappingon find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data.
- Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared
delete-by-id-before-hook-repoint-retired—abeforeDeletehandler on a BY-IDdelete()assigningctx.input.ida DIFFERENT id, to move the delete onto that row→ delete the other row explicitly —ctx.ql.delete(object, otherId)/ctx.api— and let the addressed delete proceed orthrowfrom the handler to stop it; to delete MANY rows, have the CALLER pass{ multi: true, where: … }. Writing the SAME id back is unaffected and stays legal.- Why not automatic: The by-id target of an
update()ordelete()is now IMMUTABLE inside abefore*handler, on both verbs, cleared or rebound.delete()was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebindingprevious(the fix that first made a single-row delete bindpreviousat all), soafterDeleteand the roll-up recompute saw the row actually deleted. It now refuses withHookTargetRebindError/ERR_HOOK_TARGET_REBIND,path: 'by-id', exactly as theupdate()twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.
- Why not automatic: The by-id target of an
Read this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. That re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on update() (the write landing on a row whose pre-image, readonlyWhen locks and validation rules were never evaluated) simply did not apply to it. The engine change that dispatches before* hooks per matched row on a bulk write therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as a finding of its own. The 2026-08-09 maintainer ruling on that finding closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's input.id, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and "a hook silently redirects which row gets deleted" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building update() the same re-resolution, stays excluded by the recorded ruling that extended per-row hook semantics to before* hooks on bulk writes ("do not silently pick re-resolution instead").
Why this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as hook-register-empty-object-target-refused and hook-context-session-roles-retired at this step: FIRST, there is no source to convert — a HookContext is constructed per write and never persisted, so no sys_metadata row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is unknown. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant "delete that row INSTEAD" or "delete that row TOO".
What makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. ADR-0058 Amendment II.2.
- Done when: No
beforeDeletehandler assignsctx.input.idanything but the id it arrived with — grep handler bodies for assignments intoinput.idand rewrite each into an explicitctx.ql.delete()for the other row, a caller-side{ multi: true, where: … }, or athrow. A delete-heavy smoke run completes with noHookTargetRebindError(ERR_HOOK_TARGET_REBIND,path: 'by-id',event: 'beforeDelete') — and any that does raise names itsexpectedIdandobservedId, which identifies the handler that moved the target. driver-aggregate-undeclared-key-aliases-removed—driver aggregate() call argument — query.aggregate and aggregations[].func→ query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared- Why not automatic:
SqlDriver.aggregateandRemoteTransport.aggregateeach read two aliases the Query Protocol has never declared:query.aggregations || query.aggregateandagg.function || agg.func. "Never declared" is measured, not assumed —git log -Soverdata/query.zod.tsfinds no commit that ever introduced either name, there is noretiredKey()tombstone and no alias-table entry for them (the file's only alias table isSortNode'sdirection→order), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — the family of the org-axis red-line gate that read only rejected aliases while its own fixtures spelt them, so its tests stayed green and the rule stayed dead: a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across todashboard/pagemeasures:aggregateIS the canonical key there andfuncIS a declared, loudly-suggesting alias (DatasetMeasureSchema, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query throughQueryASTSchema.parse()on this path. The enforced channel is tsc at the call site, once the parameter isDriverQuery— and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, asdata-driver-find-stream-retired(IDataDriver.findStream, removed with no tombstone because nothing parses a driver object),storage-service-list-retired(the zero-consumerIStorageService.list, whose two adapters answered differently and both incompletely) andactor-user-roles-to-positions(thectx.userrolesalias, closed at once on the maintainer's word rather than given a window). The removal ran in a fixed order — the fixtures re-spelt first, the two alias branches deleted second, the parameter narrowed toDriverQuerylast — because the reverse order yields red nobody can explain. ADR-0049 / ADR-0087. - Done when: No caller passes
aggregate:to a driver'saggregate(), and no aggregation entry spells its functionfunc:; both are writtenaggregations:/function:. An inline literal still using either old spelling no longer type-checks (TS2353 at the call site). An untyped JS caller that keeps writingaggregate:silently receives no aggregate column — the grouping still happens, the measure is simply absent — and one that keeps writingfunc:receives INVALID_QUERY / 400 naming the undeclared function, identically on the local driver and the Turso remote transport.
- Why not automatic:
driver-capabilities-inert-bits-removed—data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache→ (removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal arequeryDateGranularity,autonumberandbatchSchemaSync)- Why not automatic: Retiring
IDataDriver.findStream(it had no production caller, and two of its three implementations read the whole result set into memory before yielding a row) leftDriverCapabilities.streamingpointing at a capability the contract no longer declares, and the follow-up audit checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader —queryDateGranularity(engine aggregate dispatch + checkDateBucketParity),autonumber(engine defers generation to the driver),batchSchemaSync(engine ANDs it with method presence, because a subclass can inheritsyncSchemasBatchfrom a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their.describe()strings promised engine adaptation ("if false, ObjectQL will filter/sort/paginate in memory") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declaredstreaming: falsewhile implementingfindStream; InMemoryDriver declaredstreaming: trueover a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate ondriver.beginTransaction, aggregate pushdown ontypeof driver.aggregate, schema sync ontypeof driver.syncSchema, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata —supportsliterals live in driver classes andDriverConfig.capabilitiesis plugin TS configuration, neither ever asys_metadatashape (the stack-tree neighbour,datasource.capabilities, was retired separately, as a whole block nothing read) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted becauseDriverCapabilitiesSchemais not.strict()and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another.batchSchemaSyncalso drops its.default(false)for.optional()— absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078. - Done when: No
supportsliteral orDriverConfig.capabilitiesobject authors any of the 31 retired bits — a driver class that still writes one fails tsc againstIDataDriver.supports(the bit isnever), and a parsed config fails with the per-key prescription. The three in-repo drivers (memory / mongodb / sql) declare only live bits; cloud's TursoDriver keeps compiling via its...super.supportsspread (its stale explicit overrides are cleanup, tracked cloud-side). Engine behaviour is byte-identical: every removed bit had zero readers, and the three live bits keep their readers (engine.ts autonumber defer / aggregate dispatch, plugin.ts + engine.ts batched schema sync, verify date-bucket parity).
- Why not automatic: Retiring
driver-sql-distinct-bare-filter-typed—SqlDriver.distinct() third argument — any value→ a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope- Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything.
distinctis not declared onIDataDriver, so neither the narrowing ofIDataDriver's query parameters toDriverQuerynor the follow-through that brought five drivers' implementations in line ever reached it, and it keptfilters?: anywhile its body said something far more specific —applyFilters(builder, filters)is handed the ARGUMENT ITSELF, never a.whereoff it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument —distinct('orders', 'product', 'completed')— used to type-check and resolve the UNFILTERED set, becauseapplyFiltersemits no predicate at all for a truthy non-object, non-array filter. A call meaning "which products among completed orders" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the dispositiondata-driver-find-stream-retired,storage-service-list-retired,actor-user-roles-to-positionsanddriver-aggregate-undeclared-key-aliases-removedalready carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it:FilterConditionis an open map ([key: string]: any) because a filter key IS a field name, so a query envelope{ object, where }is structurally a valid filter — one constraining columns namedobjectandwhere— and so is a FilterArray. Both reachdistincttype-checked and are refused at run time, loudly, with INVALID_FILTER / 400.driver-memory's opposite half — where the BARE spelling returns the unfiltered set in silence — stayed open under the maintainer's 2026-08-05 investment freeze on driver-memory, which was lifted on 2026-08-11; it is still open, now unexcused rather than deferred (the measurement that found the two drivers reading this argument differently split the fix: the sql half is this entry, and the memory half was held back by that freeze). ADR-0087. - Done when: No caller passes a non-object to
distinct()'s third argument. A scalar there is now a compile error (TS2345: Argument of type 'string' is not assignable to parameter of type 'FilterCondition'); rewrite it as the bare filter it was always meant to be —'completed'becomes{ status: 'completed' }. ⚠️ That is NOT an equivalent rewrite: the old spelling returned the UNFILTERED set, so the answer changes once fixed, and the changed answer is the one the call always meant. An untyped JS caller gets no compile error and no behaviour change — for them this entry is the only notice that the spelling never filtered anything. A query envelope or a FilterArray in that slot still compiles and is rejected at run time with INVALID_FILTER / 400.
- Why not automatic: This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything.
engine-dotted-projection-refused—engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (account.name) — the direct engine path, not the REST ingress→ read the related record withexpand({ expand: { account: { object: '<target>', fields: ['name'] } } }), keeping the reference column itself infields— the relation is carried by that column and projecting it away leaves expansion nothing to resolve, the same silent no-op a nested projection omitting the relatedidproduced before the engine began keeping that join key itself; or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress prescribes when it refuses a dotted projection, and the sort axis when it refuses a dotted sort- Why not automatic: The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted entry instead of widening the response to every field (
assertProjectionFieldsExist,400 INVALID_FIELD), which covers everything reachingfindData. A caller reachingengine.find()/engine.findOne()DIRECTLY passed through none of it, and that caller set was measured, not assumed: a flowget_recordnode's authoredfields: ['name', 'account.name']parses (GetRecordConfigSchemarestricts nothing), travels verbatim intodata.find(...), cleared the engine's head-only projection filter on its head segment (accountIS a field), and reached the driver as a projection column — where SQL renders"account"."name"against a table that was never joined, the DB answersno such column, and the driver's unknown-column recovery ladder — there so an unknown column never reads as "no rows" — retriesselect('*'). The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.
- Why not automatic: The REST ingress closed the PROJECTION axis' dotted leg first, refusing a dotted entry instead of widening the response to every field (
Ruled by the maintainer on 2026-08-12: a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths "via populate"; a measurement found that NO populate step exists — once the spec and docs stopped prescribing a dotted fields path, that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to *), a registry-less host gets no verdict (the driver-side recovery ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted fields inside a nested expand degrades to an observable warning rather than a refusal — expandRelatedRecords' pre-existing graceful-degradation catch swallows every expand failure, the same posture the formula-sort refusal (engine-find-formula-order-by-refused) records for the same catch.
This is a CODE-path API, not stored metadata, so — like engine-find-formula-order-by-refused at this step — there is no sys_metadata row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between expand and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration — the line analytics already takes for a relation-traversing dotted measure, refused with a 400 naming the caller's spelling rather than computed against the wrong column. ADR-0112.
- Done when: No
engine.find/engine.findOnecall site passes a dottedfieldsentry, no flowget_recordconfig authors one, and no saved report'squery.fieldsnames one — grep flow definitions and report definitions for afieldsentry containing a., and rewrite each toexpand(keeping the reference column projected) or to a denormalised stored column. Reads complete with noINVALID_FIELDwhose message says "follows the relationship" or "a dotted path", and no "Failed to expand relationship field" warning whose error text does. engine-find-formula-filter-refused—awhere/ filter naming aformulafield — at BOTH doors: the REST ingress (assertFilterFieldsExist, covering everything that reachesfindData) and the engine seam itself (engine.find/findOne/count/aggregate/update/delete), which saved reports, flows and dashboard widgets reach directly→ denormalise the value onto the object (a stored field, written when the source changes) and filter that — deliberately the same remedy, in the same words, the SORT axis prescribes when it refuses a formula sort, at the ingress and at the engine, and the SEARCH axis prescribes when it refuses a formula search field;summaryandautonumberfields need NO action, because both get real maintained columns and filter correctly- Why not automatic:
formulais the one field type no driver materialises a column for, and FILTER was the last of the three query axes still fail-open on it: SORT refuses it (at the REST ingress and at the engine) and SEARCH refuses it by name, while awhereon aformulafield cleared every gate precisely BECAUSE the object declares the field, reached a driver with no column behind it, and answered 200 with zero rows. Measured on a realObjectQLwithis_openaformulaover the storedstatuscolumn:where {is_open: true}andwhere {is_open: false}each returned 0 rows with NO error, while the controlswhere {status: 'open'}returned 4 rows andwhere {subtask_total: 5}(asummary, which HAS a column) returned 1 row.
- Why not automatic:
BOTH directions are wrong and the false one is the dangerous one: the same predicate against a STORED boolean returns every matching row, so a filter meaning "not yet done" silently became "no records at all" — a row SET changed under a 200, which no amount of inspecting the response can reveal, and the formula READS correctly in that very same response, so the field is visibly populated and simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: a refused sort returns the same rows in a different order, a refused filter changes which rows exist.
Both doors now refuse it with 400 INVALID_FIELD, naming the offending key path and carrying the remedy sentence — the ingress gate (assertFilterFieldsExist, @objectstack/metadata-protocol) for everything reaching findData, and assertFilterIsMaterializable (@objectstack/objectql, filter-comparand-shape.ts) at the engine's own filter seam, which every caller-supplied where passes through whichever verb it arrived by. Both judge the field by the SAME @objectstack/spec/data predicate the SEARCH axis uses (isVirtualSearchField / SEARCH_VIRTUAL_TYPES, which holds formula and nothing else), so gate and drivers cannot disagree about which types have a column: a gate widened to the spec's COMPUTED_VALUE_TYPES (the WRITE contract) would refuse two working types. DOTTED filter paths are deliberately not judged on this axis at either door.
This is a CODE-path API, not stored metadata, so — like engine-find-formula-order-by-refused and engine-dotted-projection-refused at this step — there is no sys_metadata row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not filter post-hoc instead — driver.find has already applied limit / offset, so a predicate applied after the formulas are evaluated would filter an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.
AUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved report's query.filter (sys_saved_report) is forwarded VERBATIM into engine.find by plugin-reports (report-service.ts, where: q.filter), bypassing the ingress gate entirely; flow node config.filter and dashboard widget filters are author-written the same way. A report or flow authored to filter on a formula field used to run and quietly return the wrong row set; it now fails loudly, with the remedy in the message.
Registered on the ruling inherited from the SORT axis — its engine refusal was registered in this ledger although no stored row needs rewriting, because the ledger is the one channel that carries its rewrite instructions to the author — re-affirmed for this axis at triage on 2026-08-13: the shape is identical to the sort axis and the consequence here is larger. ADR-0112.
- Done when: No filter names a
formulafield on any surface — grep your saved report definitions (sys_saved_report.query.filter), flow nodeconfig.filter, dashboard widget filters and view filters for a filtered field whose object declares it as aformula, and denormalise each onto a stored column written when the source changes. Asummary/autonumberfield needs no action: both have real maintained columns and filter correctly. Reads complete with noINVALID_FIELDnaming a virtualformulafield in a filter, at either door. engine-find-formula-order-by-refused—engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming aformulafield — the direct engine path, not the REST ingress→ denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress prescribes when it refuses a dotted or formula sort; asummaryfield is unaffected and still sorts, because it gets a real maintained column- Why not automatic: The SORT axis is closed at the REST ingress for an unknown field, a dotted path and a
formulafield alike (assertSortFieldsExist,400 INVALID_SORT), which covers everything reachingfindData: the list route,POST /data/:object/query, the export route and the RPC dispatcher. A caller reachingengine.find()/engine.findOne()DIRECTLY passed through none of it, and aformulaORDER BY there was dropped in silence. Measured on a real driver:ascanddesccame back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.
- Why not automatic: The SORT axis is closed at the REST ingress for an unknown field, a dotted path and a
Ruled by the maintainer on 2026-08-10: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the ingress sort hint's stored-field prescription. The engine's documented internal-caller tolerance (assertProjectionFieldsExist's docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The sweep of every in-tree orderBy reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (created_at, updated_at, version, priority, scheduled_for, started_at, next_run_at, recorded_at, id), and no shipped object in the repo declares a formula field at all. So no internal path shipped, and there is no flag to opt back into the drop.
This is a CODE-path API, not stored metadata, so — like hook-register-empty-object-target-refused at this step — there is no sys_metadata row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — driver.find has already applied limit / offset, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.
ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's query.orderBy (sys_saved_report) is forwarded verbatim into engine.find by plugin-reports, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested expand sort raises this refusal inside expandRelatedRecords, whose pre-existing graceful-degradation catch swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. ADR-0112.
- Done when: No
engine.find/engine.findOnecall site sorts by aformulafield, and no saved report'squery.orderBynames one — grep your report definitions for anorderByfield whose object declares it as aformula, and denormalise it onto a stored column written when the source changes. Asummary/ rollup field needs no action: it has a real maintained column and sorts correctly. Reads complete with noINVALID_SORTnaming a formula field, and no "Failed to expand relationship field" warning whose error text names one. engine-update-upsert-retired—data.engine.update options.upsert→ (removed — never implemented; express create-if-absent explicitly:findOnefirst, theninsertorupdateon what you find)- Why not automatic: The
upsertflag promised insert-if-absent onengine.update()but no engine or driver path ever read it: the key was declared on both update-options schemas and allowlisted by the unknown-option gate, yetObjectQL.update()never referenced it and it was not a driver pass-through key —{ upsert: true }was accepted and silently dropped and the update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). Any future first-class upsert must reconcile with the engine's not-found gate — a by-id update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the flag is removed rather than implemented here. - Done when: No caller passes
options.upserttoengine.update(); a call that includes it is refused loudly (the engine gate and both schemas quote one removal prescription) instead of succeeding with the option silently ignored.
- Why not automatic: The
enhanced-api-error-field-errors-renamed—api.enhancedApiError.fieldErrors→ fields- Why not automatic: The wire has always carried
fields— the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all sayfields, and nothing ever emittedfieldErrors, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4 (the field-level error code catalog). - Done when: No consumer reads
error.fieldErrors; per-field validation detail is read fromerror.fields, and constructing an EnhancedApiError withfieldErrorsfails to parse with the rename prescription instead of silently losing the array.
- Why not automatic: The wire has always carried
etl-pipeline-layer-retired—automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and theETLfactory — 9 defs, 27 exported names)→ (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation isConnectorSchema.syncConfig(integration/connector.zod.ts), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync.AutomationEngine.registerConnectorrunsConnectorSchema.parseand stores the parsed definition; nothing readssyncConfigback off it, and the key has no reader outsidepackages/specat all — the same measurement that deletedsyncConfig.schedulein @objectstack/spec 17 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is itsactions: a flow'sconnector_actionnode resolves the registered handler and awaits it, so an author who needs data actually moved drives it from there. Per-field value transformation on import isshared/mapping.zod.ts, whosetransformis applied row by row by the REST import path and recorded key by key inpackages/spec/liveness/mapping.json; scheduling issystem/job.zod.ts. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)- Why not automatic: The reading the spec dual-source cleanup used to retire L1
DataSyncConfig(its automation copy deleted as dead), re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed anETLPipeline. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (apps/docs/.source/*.ts), not executors; objectui has no reference at all; there is noliveness/etl.jsonorpipeline.json, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (liveness/mapping.json), which is the contrast that makes the absence meaningful rather than an oversight. Theetlstring in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the retry-vocabulary entry forETLPipeline.retry(a third retry-policy vocabulary the retry convergence had not covered), absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised:packages/spec/docs/SYNC_ARCHITECTURE.mdnamedETLPipelineas the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down toscript | Custom JavaScript/Python. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️etl-retry-converged-onto-retry-policyis SUBSUMED here, the way theactivationEvents, dynamic plugin-loading and widget / i18n retirements each let an earlier tombstone go with the shape that carried it: both land in the unreleased protocol 17, so composed, a rename ofretry.maxAttemptson a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. ThemaxAttemptsretiredKey()tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer aretryblock to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078. - Done when: No source imports
ETLPipeline,ETLPipelineParsed,ETLPipelineSchema,ETLPipelineRun(Schema),ETLSource(Schema),ETLDestination(Schema),ETLTransformation(Schema),ETLEndpointType(Schema),ETLTransformationType(Schema),ETLSyncMode(Schema),ETLRunStatus(Schema)or theETLfactory from@objectstack/spec/automation;tscreports TS2724/TS2305 on any that survives. Every author who was pointed at L2 has been re-pointed by name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommendsETLPipelineas L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaringsyncConfigand an import declaringmapping.transformboth behave exactly as they did in 16.x.
- Why not automatic: The reading the spec dual-source cleanup used to retire L1
export-axis-opt-in—security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)→ an explicitallowExport: trueon the object entry (or the*wildcard) of every permission set whose holders are meant to keep exporting- Why not automatic: A secure-default FLIP, not a shape change — the same class as
rest-requireauth-default-flip(ADR-0056 D2, protocol 12) andaction-descriptor-resume-authority-default-flip, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17,allowExportunset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite "Export Lists" and SAPS_GUI61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writingallowExport: truewherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writingfalsewould revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — at 17.0.0admin_full_accessandorganization_admincarried the grant explicitly, ON A*WILDCARD, and protocol 18 REMOVES it (seeadmin-export-wildcard-removed: the wildcard made the axis undeniable for an org admin, so from 18 an admin exports only what an app set grants — do not read this clause as a standing promise that the built-ins keep exporting) — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand.member_defaultdeliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set grantingtruegrants export, andfalseis authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it:viewAllRecords/modifyAllRecordsare "may see all data", not "may take a bulk copy". Registered (backfilled) by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the export axis, and its extension to the CSV attachments scheduled reports mail out, both predate the gate that makes a breaking changeset state its ADR-0087 disposition. ADR-0087. - Done when: Every environment-authored permission set has been READ and decided, not just parsed: each object entry whose holders should keep exporting carries
allowExport: true, and each one that should not is knowingly left without it. The verify loop is behavioural, because nothing fails at parse time — sign in as a holder of each affected set and confirm an export either succeeds or is refused as intended, including the report export path, which this change brought under the same axis. ⚠️ Silence is not success here: a deployment that upgrades without editing anything is VALID metadata whose ordinary users have quietly lost export, and the first sign will be a user report rather than an error. Checkmember_defaultexplicitly — it is the set most likely to have been carrying export by inheritance.
- Why not automatic: A secure-default FLIP, not a shape change — the same class as
export-field-meta-constraints-retired—@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built bybuildFieldMetaMap, reached asPreparedImport.metaMapfromprepareImportRequest)→ the object schema you already hold — readfields[name].required/.system/.readonly/.defaultValue/.min/.max/.minLength/.maxLengthoff the sameObjectSchemayou passed tobuildFieldMetaMap, which is where the ENGINE reads them and therefore the only copy that cannot drift- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth:
buildFieldMetaMap(schema)DERIVED each one from the veryschemaits caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (firstMissingRequiredField/firstConstraintViolation, added when the dry run was found skipping the field-level validation the real write ran) — and the maintainer's 2026-08-06 ruling D (a validate-only protocol operation, so the dry run's prediction is the engine's verdict by construction) retired that mirror: the dry run now asksDataProtocol.validateDatafor the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (packages/restitself, and all five in-repo dependents of@objectstack/rest: runtime, cli, verify, plugin-auth, plugin-dev) and theobjectuisibling; plugin-auth's identity import forwardsprepared.metaMapintorunImportbut reads only the presentation keys throughcoerceRow. Why this needs a ledger entry despite that sweep: it is thefindStream/IStorageService.list/actor-user-roles-to-positionsdisposition — a published TS surface with NO spec schema, so there is noretiredKey()tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (@objectstack/rest14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer readingmeta.requiredafter the upgrade getsundefinedwith no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape —required/min/maxLengthand the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so noobjectstack migrate metatransform can reach them. ADR-0049 / ADR-0087; this is the removal the dry-run change deliberately deferred to a sweep of its own. - Done when: No code of yours reads any of the eight off a
buildFieldMetaMap/prepareImportRequestresult. Grep your sources for.required/.hasDefault/.minLength/.maxLength/.min/.max/.system/.readonlyon anExportFieldMeta-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped orany-typed read compiles clean and silently becomesundefined— assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. NotehasDefaulthas no one-to-one replacement key: it was the derived predicatedefaultValue != null, mirroring the engine'sapplyFieldDefaultsgate, so readfields[name].defaultValueand apply that same!= nulltest yourself.
- Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth:
external-lookup-message-queue-families-retired—data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)→ (removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data:object.external(ObjectExternalBindingSchema, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata;data/external-catalog.zod.tsis that federated path's catalog surface and is untouched. For message queues: the LIVE surface iskernel/events/integrations.zod.ts'sEventMessageQueueConfig(EventBusConfig.messageQueue), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)- Why not automatic: Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no
sys_metadatadoor reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers.ExternalDataSourceSchema.authentication.configis a record of unknown whose own docblock example wrote"clientSecret": "..."inline, andMessageQueueConfigSchema.sasl.passwordwas a required inline broker credential — the class of thesys_metadatacleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connectorauthentication) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (object.externalbindsObjectExternalBindingSchema— remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (DatasourceSchemaunder identical exclusions) returning hits in the same run. The consumed MQ near-namesakekernel/EventMessageQueueConfigdeliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (the lesson of the plugin sandboxing config that was never wired to anything: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source orsys_metadatarow for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, theui/interaction configs, the widget / i18n shapes and the sweep of five declared-but-inert surfaces — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ Thedata/ExternalFieldMapping:transformtombstone of the field-mapping transform retirement (one of that retirement's three spellings; the whole transform union left because no runtime ever executed any of its five members) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The baseshared/FieldMappingtombstone and theintegration/ConnectorFieldMappingspelling are untouched and still rejecttransformwith that retirement's prescription. ⚠️ The reopen trigger of the maintainer's 2026-08-12 ruling on the cleartext sink — it closed each artefact's contract (Option A) and parked the class-levelsys_metadatawrite-boundary guard (Option B) until "a third measured artefact-type surface" — is NOT met by this census: that guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed. - Done when: No code imports
ExternalLookup(Schema|Parsed),ExternalDataSource(Schema),ExternalFieldMapping(Schema|Parsed),MessageQueueConfig(Schema|Parsed),MessageQueueProvider(Schema),TopicConfig(Schema|Parsed),ConsumerConfig(Schema|Parsed)orDeadLetterQueue(Schema|Parsed)from@objectstack/spec,@objectstack/spec/dataor@objectstack/spec/system— every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity indata/external-lookup-retirement.test.tsandsystem/message-queue-retirement.test.ts). No metadata document needs editing, because none could ever carry one of these shapes.kernel/EventMessageQueueConfig(with its inline provider enum and no credential key),data/external-catalog.zod.ts,object.externalandkernel/DeadLetterQueueEntrysurvive unchanged.
- Why not automatic: Both families are the verdict of the 2026-08-12 census of spec schemas that permit inline credentials (fork (b): no
field-runtime-create-withdrawn—PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalonefielditems)→ Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field infields— or declare it in the object source (**/*.object.ts) and redeploy- Why not automatic: The
fieldregistry entry declaredallowRuntimeCreate: trueand the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository:PUT /api/v1/meta/field/showcase_task.zz_probeanswered 200 with {"success":true,"state":"active","message":"Saved field …"}, the row persisted, andGET /api/v1/meta/object/showcase_taskthen listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (GET /meta/field/showcase_task.zz_probe-> 200,_diagnostics.valid: true), which makes it well-formed and universally inert rather than malformed. The seam is thatfieldis the ONE declared type with no standalone existence: fields are authored inside the object (ObjectSchema.fields), afieldwrite mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent —applyRegistryWriteThroughroutes onlytype === 'object', andfilePatterns(**/*.field.ts) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20gate.fieldscall sites, physical schema/migrations, and cold boot vialoadMetaFromDb); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT theapiwithdrawal's rationale reused (api-runtime-create-withdrawn): that ruling rested on "zero business pull", and "add a field" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes:objectkeepsallowRuntimeCreate: true, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key.allowRuntimeCreateis a PLATFORM registry value, not an authorable one, and no authored source changes — an**/*.object.tsfile valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same dispositionapiandBatchOptions.validateOnlytake. ADR-0049 / ADR-0087. - Done when: No caller creates a standalone
fielditem through the runtime metadata API.PUT /api/v1/meta/field/{object}.{name}answers 403 withcode: "NOT_CREATABLE"and a body naming both flags (allowRuntimeCreate=false, allowOrgOverride=false) and the prescriptionPUT /api/v1/meta/object/:object with the new field infields``. The plural spellingPUT /api/v1/meta/fields/{object}.{name}folds onto the singular and earns the same refusal — verify it, because it was a separate door until 2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the change:PUT /api/v1/meta/object/{name}with a new entry infieldsstill answers 200, andGET /api/v1/meta/object/{name}READS THE NEW FIELD BACK (assert onbody.data.item.fields, notbody.item, which is undefined and makes an empty read look like a pass). Assert a DECLARED field is present in the same response, so a dead read cannot be what makes the check pass. ⚠️ The field overlay refusal is untouched and must stay: overwriting a field a code package ships is still 403NOT_OVERRIDABLE, a different gate for a different question — making field OVERRIDES legal was never part of this decision. DISPOSITION OF EXISTING ROWS:fieldrows already written through the retired channel stay insys_metadataand are INERT — they were inert before this change too, since no read path ever composed them into an object, so nothing that used to work stops working and no data is silently reinterpreted. They remain self-readable by name and still report_diagnostics.valid: true, which asserts only that the isolated document is well-formed (the envelope has no "in effect" axis). They may be deleted at leisure:deleteMetaItemis deliberately NOT gated by this refusal, so repair stays possible. An operator who needs the write door back on one deployment setsOS_METADATA_WRITABLE=field; note this unlocks the WRITE only — the field still will not reach its object, which is why it is a diagnostic and not a workaround.
- Why not automatic: The
filter-regex-options-retired—data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request→ $icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement- Why not automatic: Like
driver-aggregate-undeclared-key-aliases-removedanddriver-sql-distinct-bare-filter-typed, this entry records a LENIENCY being withdrawn rather than a declared surface:$regexwas never inFILTER_OPERATORSand never a key onStringOperatorSchema. That is measured, not assumed —git log -S'$regex'overpackages/spec/srcreturns only doc comments describing how$containsLOWERS to MongoDB (Contains substring - SQL: LIKE %?% | MongoDB: $regex), plus the retirement's own contract-half change, which added the name solely asRETIRED_FILTER_OPERATORSprescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata.FilterConditionSchemais an OPEN RECORD (z.record(z.string(), z.unknown())) because a filter key is a field name, so a stored{ name: { $regex: 'acme.*' } }parses GREEN and always will — aretiredKey()tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends:driver-sqland Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (soa.bmatched only the literala.band the regex was silently never a regex),driver-memoryand objectql'shavingran it as a realRegExp(so the same filter also matchedaxb, and an INVALID pattern was caught and answeredfalse— zero rows, in silence), anddriver-mongodbrefused it with a bareErrorcarrying nocodeand nostatus. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits insemanticrather than among the mechanical transforms: rewriting$regexto$icontainsis NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the maintainer's 2026-08-06 ruling (option B: retire$regexloudly and add$icontains, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the$icontainsdeclaration, the$containsfamily pinned case-sensitive, and theRETIRED_FILTER_OPERATORSprescriptions) landed before the gate that makes a breaking changeset state its ADR-0087 disposition existed, and so was never asked for a ledger entry; the driver half is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087. - Done when: No stored filter and no request
wherespells$regexor$options— grep the stack for both. Each one is rewritten by asking what the pattern MEANT, not by transliterating it: a bare substring pattern becomes$icontains(or$containswhen the match must stay case-sensitive), and its metacharacters are dropped rather than escaped, because they were never honoured as a regex on the SQL family in the first place. ⚠️ Expect the answer to CHANGE on any stack that ran ondriver-memory,driver-mongodbor objectqlhaving, where the pattern really was evaluated as a regular expression; on the SQL family the rewritten filter returns what it always returned. A pattern that genuinely needs alternation, anchoring or character classes has no filter-level replacement — move that predicate into a formula field or a server-side view, or open an issue for it. Verify by loading the stack: a surviving$regexor$optionsis answered INVALID_FILTER / 400 with a message naming the replacement, on every backend.
- Why not automatic: Like
flow-retry-max-retries-required—flow.errorHandling.maxRetries (under strategy: 'retry')→ an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'- Why not automatic: maxRetries had two defaults — FlowSchema
.default(0)and the engine'smaxRetries ?? 3— so an unstated count retried 0 times through the schema and 3 times through a hand-built definition. With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactlystrategy: 'fail', so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's. - Done when: Every flow declaring
errorHandling.strategy: 'retry'also declaresmaxRetries>= 1, and each count was chosen knowing a retry replays the flow FROM THE START (records re-created, callouts re-fired); flows that never actually wanted retries saystrategy: 'fail'. No flow fails to register with the maxRetries prescription.
- Why not automatic: maxRetries had two defaults — FlowSchema
hook-context-session-roles-retired—data.hookContext.session.roles→ (removed — gate onsession.userId/session.isSystem; for PRIVILEGE ask the security service, which readspermissions/positions/ posture off the execution context, ADR-0095 D3)- Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with
session.roles?.includes('admin'); ObjectQL'sbuildSession()builds the session field by field and has never writtenroles, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers readhookContext?.session?.userId; objectui'srolesare the/auth/meuser payload, a different surface; an ACTION body'sctx.sessionis a different untyped object that does carryroles, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. An earlier fix removed both readers, returning the record lock and the delegation guard to the one permission vocabulary; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so nosys_metadatarow, example or template can carry the key and there is no source for the D2 chain to rewrite — theopenApi31/activationEventsshape, one semantic TODO rather than a stack conversion. The key IS tombstoned (HookContextSchemais deliberately not.strict()— a plain delete would strip it silently, as a removed field key was measured to be, ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049. - Done when: No hook reads
ctx.session.roles; caller gating usesctx.session.userId/ctx.session.isSystem, and privilege comes from the security service (permissions/positions/ posture). Constructing a HookContext session withrolesfailstsc(the input type isnever) and failsHookContextSchema.parsewith the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it.
- Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with
hook-register-empty-object-target-refused—engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whoseexcludeObjectscancels itsobjectentirely→ name the object(s) —object: 'account'/object: ['account', 'contact']— or, for a global hook,object: '*'or noobjectkey at all; for a cancelled scope, widenobjector drop the overlapping names fromexcludeObjects- Why not automatic: An earlier breaking fix established that an empty hook target is not "no target" and closed the shape at the two METADATA doors —
HookSchema.object's refine andhook-binder.ts'snormalizeObjects.engine.registerHook, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write:''is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (that fix's headline failure mode — blank intent taking the broadest possible blast radius);[]and['']are truthy but admit no object name, so the entry could never fire. The laterexcludeObjectsface (a hook global except for the objects it names) then brought a fourth shape reached by arithmetic rather than by one bad name: anobjectlist every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.
- Why not automatic: An earlier breaking fix established that an empty hook target is not "no target" and closed the shape at the two METADATA doors —
No mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — object: '' could have meant '*' (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that '' is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why the excludeObjects change declined to do it in passing.
This is a RUNTIME registration API, not stored metadata, so — like hook-context-session-roles-retired at this step — there is no sys_metadata row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a record-change flow's start node forwards config.objectName verbatim into registerHook (RecordChangeTrigger.start), so a flow authored with a blank objectName used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the kernel:bootstrapped binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. ADR-0078.
- Done when: No
registerHookcall site passes an emptyobjecttarget, and none passes anexcludeObjectslist covering every name in itsobjectlist. Everyrecord-changeflow start node declares a non-blankconfig.objectName, or omits the key if the flow is genuinely meant to fire on every object. Boot completes with no "[ObjectQL] Hook ... declares an emptyobjecttarget" throw and no "[record-change] ... not bound" warning naming a flow you expect to fire. http-request-errors-total-retired—observability.SEMCONV.httpRequestErrorsTotal (the published metric name http_request_errors_total{method,route}, and its emission from the runtime dispatcher's per-route wrapper)→ the 5xx rate ishttp_requests_total{status=~"5.."}— the TRANSPORT emits that family through theIHttpServer.afterResponseseam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is theerrorReporter(Sentry / Datadog / your adapter), which still fires on every 5xx throw- Why not automatic: ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name.
SEMCONVpublishedhttp_request_errors_totalas part of a stable namespace declared "so hosts can wire alerts/dashboards against it", but the only emitter was@objectstack/runtime'sinstrumentRouteHandler, applied only by the dispatcher's own route Proxy — so the series never saw auth'sgetRawApp()mount, the REST data API viaRouteManager, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (the request counter, then the latency histogram, both through the response-observing hook the transport was given) and this one could not follow:HttpResponseObservationcarries{method, routePattern, status, elapsedMs}and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors througherrorResponseBase, which sets a status and does not re-throw, so the old counter MISSED those, while itscatchincremented unconditionally, so a thrown 4xx WAS counted as an error. Andhttp_requests_totalalready carries astatuslabel, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B "move it to the transport as a status class" and D "keep it dispatcher-scoped and rename it"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, asruntime-httpserver-wrapper-retiredandenhanced-api-error-field-errors-renamed. ADR-0049 / ADR-0087. - Done when: No dashboard, alert rule or exporter config names
http_request_errors_total: the series stops receiving samples the moment 17.2.0 is deployed, so a panel keyed on it draws a flat zero that reads as a healthy server rather than as a removed metric — the one failure mode this retirement can produce, and the reason the changeset announces it loudly. A 5xx-rate panel or alert is rewritten tohttp_requests_total{status=~"5.."}and then PROVEN wider, not merely non-empty: make an auth route or a REST data-API route answer 5xx and confirm the new query moves, where the retired counter would not have moved at all. If the signal you were actually alerting on was "a handler threw rather than returning an error envelope", that is theerrorReporter, not a counter — wire an APM adapter and assert one synthetic 5xx throw arrives. In code,SEMCONV.httpRequestErrorsTotalandRUNTIME_METRICS.httpRequestErrorsTotalno longer resolve (tsc reports TS2339 at any surviving read) and nometrics.countercall names the string.
- Why not automatic: ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name.
http-server-runtime-vocabulary-retired—system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)→ (removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability issystem/metrics.zod.tsandsystem/logging.zod.ts(plusOS_SERVER_TIMINGfor timings), and liveness is the/healthendpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives ondefineStack({ server })/StackServerConfigSchema, which is unaffected)- Why not automatic: The second and final ADR-0049 pass over
system/http-server.zod.ts. The first removed the CONFIG half (HttpServerConfigSchema, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so "zero consumers in this repo" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep:plugin-hono-server, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run:MiddlewareConfig, declared twelve lines away, resolves topackages/runtime/src/middleware.ts. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source orsys_metadatarow for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as the config half's removal in this very file and the earlier removals of the dynamic plugin-loading family, theui/interaction configs and the widget / i18n shapes. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049. - Done when: No source imports
ServerEvent,ServerEventType,ServerEventSchema,ServerCapabilities,ServerCapabilitiesSchema,ServerCapabilitiesParsed,ServerStatusorServerStatusSchemafrom@objectstack/spec/system— a grep over consumer code resolves none of them, andtscreports TS2724/TS2305 on any that survives. The route-registration half of the same module still resolves (RouteHandlerMetadataSchema,MiddlewareType,MiddlewareConfigSchema,MiddlewareConfig), andStackServerConfigSchema— the one authorable server surface — is untouched: a stack declaringserver: { trustProxy, security }parses exactly as it did in 16.x.
- Why not automatic: The second and final ADR-0049 pass over
import-run-automations-declared-default-corrected—api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as "off by default for bulk"; it is now default(true), which is what the server has always done→ an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing- Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's
rest-requireauth-default-flipand this major'saction-descriptor-resume-authority-default-flipare: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts withbody?.runAutomations !== false, i.e. an omitted flag runs automations, and has since the flag was first honoured — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED:.default(false)in@objectstack/spec's JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference toCreateImportJobRequestSchemais the declarativeImportJobApiContractscatalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialisedrunAutomations: falsefrom the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same dispositionnotification-list-cursor-retiredtakes for the sibling default on this major, andbatch-options-validate-only-retiredbefore it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] — the per-key default fingerprint added once a flipped default was found invisible to every gate — whosefrom/tofingerprints are re-derived on every build. Maintainer ruling 2026-08-09, disposition A: the spec follows the runtime. ADR-0049 / ADR-0078. - Done when: Every import request of yours that must NOT fire triggers sends
runAutomations: falseexplicitly, rather than omitting the key and trusting the old declared default. The check is worth doing precisely where it looks unnecessary: if you build the body by parsing it throughImportRequestSchema(or the published JSON Schema) and then send the PARSED object, your bulk loads were running with automations OFF and will now run with them ON — that is the only class whose behaviour changes, and it changes toward what an unvalidated caller always got. ⚠️ Behaviour on the wire is deliberately UNCHANGED and should be verified as such: a body that omitsrunAutomationsfired triggers before this change and fires them after, andrunAutomations: falseturns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to.dryRunis unaffected and still runs NO automations whatever the flag says: it asks the engine's validate-only write path for its verdict, and that path deliberately fires no hooks.
- Why not automatic: A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's
job-retry-policy-constraints-tightened—job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)→ maxRetries <= 10, and backoffMultiplier >= 1- Why not automatic: The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had:
maxRetriesis capped at 10 andbackoffMultiplierfloored at 1. Neither has a lossless rewrite. ClampingmaxRetries: 20to 10 would halve a retry budget its author chose, and abackoffMultiplierbelow 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call. - Done when: Every job declaring
retryPolicyparses: nomaxRetriesabove 10 and nobackoffMultiplierbelow 1 remain, and each adjusted value was re-chosen knowing a retry re-runs the handler with its writes and callouts. No job fails to register with the retry-policy bound prescription.
- Why not automatic: The RetryPolicy converged onto one declaration from its automation and system copies keeps the automation side's bounds, which the job side never had:
notification-list-cursor-retired—api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)→ a largerlimit— the route answers the newest N notifications and has no page 2. There is no replacement forcursor, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removedlimitdefault, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed- Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made
unreadCountreally count the whole inbox).cursorwas declared on the request and on the response and honoured on neither: the dispatcher domain readsread/type/limitand nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This isdata.query.cursor(query-cursor-retired) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. Thelimitdefault goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (the fix for request bodies never checked against their declared schemas wired the catalog's requestSchema to the real entry for BODIES only), so.default(20)never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp;.optional()plus prose is true about both the schema and the server. No constraint (.int()/.max(200)) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (the silent strip measured when a field key pruned from a non-strict schema still parsed and simply vanished, ADR-0104). SocursorisretiredKey()on both halves, typedneverfor tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a storedsys_metadatarow, and these two shapes are HTTP-only — nobody authors aListNotificationsRequestand nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same dispositionBatchOptions.validateOnly(a declared dry-run that wrote for real) and theAnalyticsQueryRequestenvelope keys already take in this major. Thelimitdefault is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (the table that closed the blind spot where a default or constraint change on an authorable key was recorded by no gate), whosefrom/tofingerprints are re-derived on every build. ADR-0049 / ADR-0078. - Done when: No caller sends
cursortoGET /api/v1/notificationsand no SDK call site passes it:client.notifications.list({ cursor })is atscerror (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Readingresponse.cursorno longer type-checks either, and always answeredundefinedbefore. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying?cursor=…is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change.unreadCountis untouched (it was the jointly ruled repair's business) and still reports the total across the whole matching inbox rather than the window. A caller that omittedlimitreceives the same 50 rows it always received.
- Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with the repair that made
package-uninstall-explicit-all-tenants—protocol.deletePackage({ packageId }) with noorganizationId(and its transport, DELETE /api/v1/packages/:id)→ explicitallTenants: truefor a cross-tenant uninstall, or anorganizationIdto scope it- Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither
organizationIdnorallTenants: trueanswers 400TENANT_SCOPE_REQUIREDand deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant "this tenant" or "every tenant" is an intent no transform can recover:resolveActiveOrganizationIdis catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same dispositionrest-requireauth-default-flip(protocol 12) takes for its own default flip. - Done when: Every caller of
deletePackagestates its tenant scope. A caller that intends an environment-wide uninstall passesallTenants: true; a caller that intends a scoped one passesorganizationId; no caller passes both. An explicitallTenants: falseis treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400TENANT_SCOPE_REQUIREDreaching a deploy script that previously "succeeded" means that script was relying on the cross-tenant reading and must now say so on purpose. The org-scoped path is unchanged — an uninstall carrying anorganizationIdstill removes that org's rows AND the environment-wide (organization_id IS NULL) rows, exactly as the orphaned-row repair left it.
- Why not automatic: An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's, while uninstall's orphaned-row defect was being repaired. That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither
plugin-activation-events-retired—kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents→ (removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed;activate()still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)- Why not automatic: Both
activationEventskeys — and theActivationEventSchematrigger vocabulary they embedded (onCommand/onRoute/ … /onView, once the kernel and studio copies had converged on the kernel's structured{ type, pattern }shape) — promised lazy plugin activation ("plugins remain dormant until an activation event fires") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writingactivationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata —StudioPluginManifestis TS configuration parsed bydefineStudioPlugin(a root schema, never part of a stack tree) andDynamicLoadRequestis a runtime request shape with no caller — so nosys_metadatarow can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned viaretiredKey()(its schema is not.strict(); a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliasesactivation/events/onActivate), and the orphanedActivationEventSchema/ActivationEventexports are removed from./kerneland./studiowith the keys (the lesson of the unwired plugin sandboxing / integrity / approval config removed before this: an exported schema with no consumer is read as a capability). Both keys took ADR-0049's REMOVE answer, not ENFORCE, while protocol 17 was still unreleased. SUPERSEDED ON THE KERNEL SIDE by the maintainer's REMOVE ruling on the rest of the plugin-runtime family (same unreleased major): the wholeDynamicLoadRequestshape — and the rest of the plugin-runtime family with it — was removed, which took this key'sretiredKey()tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer aDynamicLoadRequestto author the key INTO, so the prescription an author needs is no longer "delete this key" but "this request shape does not exist" (seeplugin-runtime-family-retired). The studio half of this entry is unaffected and still enforced by the strict manifest parse. - Done when: No
defineStudioPlugininput authorsactivationEvents— authoring it is an unknown key on the strict studio manifest and a parse error carrying the prescription. On the kernel side the stronger criterion of the plugin-runtime family's removal applies instead: there is noDynamicLoadRequesttype or schema left to author it into at all. No code importsActivationEventSchema/ActivationEventfrom@objectstack/spec/kernelor@objectstack/spec/studio(TS2305 after upgrade). Runtime behaviour is byte-identical: plugins loaded eagerly before and after.
- Why not automatic: Both
plugin-manifest-loading-retired—manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)→ nothing to re-declare — delete the key. Plugins are composed at boot:defineStackregisters them and the kernel runsinitthenstartin an order topologically resolved from each composed plugin's owndependencies/optionalDependencies(resolvePluginOrderinpackages/core/src/plugin-order.ts). For the isolationloading.sandboxingappeared to configure, note that the plugin trust tier (manifest.runtime, ADR-0025 §3.6) does not supply it either: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting thenodetier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the manifest permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation- Why not automatic: ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition that a bare-name sweep of cloud and objectui came back clean first. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside
packages/specitself: this module's own declaration, its own unit tests, theManifest.loadingembed and the generated artifacts.manifest.loading.*had zero readers inpackages/core,packages/runtimeandpackages/metadata. So the key parsed, entered the manifest, and changed nothing — an exported schema with no consumer, read as a capability, at the scale of a whole block. What made it outrank ordinary inert-key cleanup issandboxing: it declared process / vm / iframe / web-worker isolation, IPC transports and anallowedServicesACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this deadPluginHotReloadSchemawhile the only implementation body,HotReloadManager(packages/core/src/hot-reload.ts), reads a different vocabulary —HotReloadConfigSchemainplugin-lifecycle-advanced.zod.ts. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK andapplyConversionsToStoredItemmaps a metadata type onto one of its collections. A package manifest is neither —PLURAL_TO_SINGULARhas nopackages/pluginsentry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs. - Done when: No
objectstack.plugin.jsonand no stored package manifest carries aloadingkey. The enforced channel is the one place a manifest is parsed with an author present:os plugin buildrunsManifestSchema.safeParseand exits non-zero, printing the tombstone prescription, so a manifest still declaringloadingfails its build rather than shipping. TypeScript authors get it earlier still —loadingis typednever, so assigning it is atscerror. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the block, so removing it removes no behaviour. A package ALREADY INSTALLED whose stored manifest carriesloadingkeeps working — the registry'svalidate()is an explicit diagnostic and not a gate (it catches, logs[metadata_spec_invalid], and registers the item anyway, deliberately, so bad metadata is never a data outage), so such a row degrades to one log line at registration rather than a boot failure. Clear it by deleting the key from the source manifest and reinstalling.
- Why not automatic: ADR-0049 enforce-or-remove; the maintainer ruled REMOVE on 2026-08-04, on condition that a bare-name sweep of cloud and objectui came back clean first. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside
plugin-runtime-family-retired—kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation→ (removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot:defineStackregisters them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)- Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a
DynamicLoadRequest, performed a load/unload, or produced aDynamicPluginResult. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who readsDynamicLoadRequestSchemain the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (an exported schema with no consumer is read as a capability). The earlier removal of this module's discovery/sandbox config island — plugin sandboxing, integrity and approval settings that nothing read — left these five in place explicitly: "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. The maintainer's ruling of 2026-08-03 is that decision, answered REMOVE: hot loading is a real future capability, but nothing is being built and nothing pulls it, and when it is built its vocabulary enters the schema with the implementation.experimentalwas considered and rejected: it is only.describe()prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so nosys_metadatarow can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half ofplugin-activation-events-retired: that tombstone goes with the shape that carried it. ADR-0049. - Done when: No code imports
DynamicLoadRequestSchema,DynamicUnloadRequestSchema,DynamicPluginResultSchema,PluginSourceSchema,DynamicPluginOperationSchemaor any of their type aliases (DynamicLoadRequest,DynamicUnloadRequest,DynamicPluginResult,PluginSource,DynamicPluginOperation,DynamicLoadRequestInput,DynamicUnloadRequestInput) from@objectstack/specor@objectstack/spec/kernel— every one is TS2305 after upgrade, on every public entry (pinned by symbol identity inplugin-runtime-retirement.test.ts). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition throughdefineStackis unchanged.
- Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a
position-permissions-column-retired—sys_position.permissions — the "JSON-serialized array of permission strings" textarea column left the platform position table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-position.object.ts), together with the clone_position copy entry that carried it between rows→ nothing on this table — delete the key from any authoredsys_positionseed row (stackdataentries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (sys_position_permission_setrows, created in Setup or by an app's kernel:ready binder) and is resolved from the positionnameat request time. A value that was recording intent as documentation belongs indescription, which remains declared- Why not automatic: Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving
active/delegatable/is_default/nameto real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consultssys_position_permission_setrows plus the positionname, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declaredpermissions, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance forpermissions, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds
permissionson a sys_position record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for permissions next to sys_position — delete the key from any seed row; prose that was documenting intent belongs indescription. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant to grant capability, author it where it is enforced: bind permission sets to the position (sys_position_permission_setrows, created in Setup or by an app's kernel:ready binder) — the authz resolver then expands the bindings from the position name at request time.
- Why not automatic: Maintainer ruling 2026-08-20 on the finding that nothing writes or reads this column, ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving
query-array-string-agg-retired—data.query.aggregations[].function ('array_agg' / 'string_agg')→ an ordinaryfieldsquery, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged:count_distinctstays declared- Why not automatic: The stored half of this retirement is a conversion (
dataset-measure-array-string-agg-removed); this entry is the REQUEST half.QueryASTis never stored in stack metadata — it is the client SDK builder's output and thePOST /data/:object/querybody — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family:SqlDriver.mapAggregateFuncand the TursoRemoteTransport.aggregatecompile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run ondriver-mongodband on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and the maintainer's 2026-08-05 investment freeze on driver-memory and driver-mongodb had both of those backends frozen at the time (that freeze was lifted on 2026-08-11).count_distinctwas deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049. - Done when: No caller sends
array_aggorstring_agginaggregations[].function; list-style roll-ups are assembled by the caller from an ordinaryfieldsquery, or materialised as a stored field. A query still carrying either value fails to parse with the removal prescription naming it, and authoring it is atscerror at the call site;count_distinctcontinues to parse and is unaffected.
- Why not automatic: The stored half of this retirement is a conversion (
query-cursor-retired—data.query.cursor→ awherepredicate on the sort key —where: { created_at: { $gt: last.created_at } }with the matchingorderBy(the documented manual-keyset pattern)- Why not automatic: The
cursorkey promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping "until hasMore is false" never terminates. Worse than inert, it had a shipped public producer (QueryBuilder.cursor(), removed with the key). The caller-builtRecord<string, unknown>shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends
cursorand no SDK call site usesQueryBuilder.cursor(); deep pagination expresses the keyset as awherepredicate on the sort key. A query still carryingcursorfails to parse with the removal prescription, and authoring it is atscerror.
- Why not automatic: The
query-distinct-retired—data.query.distinct→groupByfor unique combinations; thecount_distinctaggregation for deduplicated counts; the SQL/memory drivers'distinct(object, field)door for one column's values- Why not automatic: The
distinctflag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degradedtotal/hasMoreto a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that "confirmed" the flag was doing something. It had a shipped public producer (QueryBuilder.distinct(), removed with the key). The count suppression is deleted in the same change —totalis truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends
distinctand no SDK call site usesQueryBuilder.distinct(); deduplication goes throughgroupBy/count_distinct/ the drivers'distinct()door. A query still carrying the key fails to parse with the removal prescription, and the REST list response reports a realtotalfor queries that used to send it.
- Why not automatic: The
query-field-node-object-form-retired—data.query.fields→ expand (expand: { owner_id: { object: 'user', fields: ['name'] } }), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (fields: ['title', 'owner_id']), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dottedfieldspath is NOT a replacement: no driver ever resolved one, and the ingress refuses it (400 INVALID_FIELD— refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by- Why not automatic: The
FieldNodeunion declared a nested-select object form{ field, fields, alias }that was inert end to end: no producer emitted it, and no consumer read.fieldsor.alias— objectql's formula projection and known-field filters, driver-sql'sselect()and driver-memory's projection all treat the list asstring[], driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection isexpand, which the engine resolves via batch$inqueries. This is a REQUEST surface —QueryASTis never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows toz.string()and callers move their own select lists. ADR-0049 / ADR-0078. - Done when: No caller puts an object in
fields[]; related records AND single related columns are read throughexpand, with the foreign-key column retained in the projection so expansion has something to resolve. Afieldsentry that is not a string fails to parse with the removal prescription, and the list/query/export routes answer 400 INVALID_FIELD naming the retired form instead of the field"[object Object]".
- Why not automatic: The
query-joins-retired—data.query.joins→ expand (expand: { owner_id: { object: 'user', fields: ['name'] } }), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (fields: ['title', 'owner_id']), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dottedfieldspath is NOT a replacement: no driver ever resolved one, and the ingress refuses it (400 INVALID_FIELD— refused since a dotted projection was found silently widening the response to every field). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis's refusal hint was corrected to prescribe, because a formula or rollup field materialises no column to sort or select by- Why not automatic: The
joinsarray was declared-but-inert: no engine or driver readquery.joinsanywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (expand, resolved by the engine via batch$inqueries), so the removal deletes the second, broken spelling rather than the capability, and the orphanedJoinNode/JoinType/JoinStrategycluster goes with the key. A REQUEST surface —QueryASTis never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078. - Done when: No caller sends
joins; related records AND single related columns are read throughexpand, with the foreign-key column retained in the projection so expansion has something to resolve. A query that still carriesjoinsfails to parse with the removal prescription (even as an empty array), and authoring it is atscerror at the call site.
- Why not automatic: The
query-window-functions-retired—data.query.windowFunctions→aggregations+groupByfor request-level analytics;SqlDriver.findWithWindowFunctions(object, query)for embedders on a SQL datasource- Why not automatic: The
windowFunctionsarray was declared-but-inert on the query path:find()never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behindSqlDriver.findWithWindowFunctions(), a driver-level door that is not on theIDataDrivercontract and whose flat input shape ({ function, alias, partitionBy?, orderBy? }) the spec vocabulary never matched —WindowFunctionNodeSchemadeclaredfield/over/framemembers the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078. - Done when: No caller sends
windowFunctionsin a query; request-level analytics useaggregations+groupBy, and embedders needing OVER-clause SQL call the SQL driver'sfindWithWindowFunctionsdoor directly. A query that still carries the key fails to parse with the removal prescription naming that door.
- Why not automatic: The
record-details-sections-object-form—ui.RecordDetailsProps.sections (therecord:detailspage component)→ an OBJECT array —sections: [{ label, columns, fields: [...] }]— replacing the string-ID list;labelgives the heading,columnsits grid width,namemakes the heading translatable, and the new siblinghideFieldsomits named fields from the body- Why not automatic:
record:detailsdeclaredsectionsas a list of section IDs —["overview", "financials"]— a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section namedoverviewwas meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui'sRecordDetailsRenderermaps every entry as an object (s.name/s.label/s.title/s.fields) with no string branch at all — a string entry spreads into a character map and renders nothing;@object-ui/types'RecordDetailsComponentPropsmirror already declaredArray<{ name?, label?, fields, ... }>; the Studio block designer can only author{ label, columns, fields };packages/linthas modelled it asnestedSectionsall along; and every page in this repo — three showcase pages plus thesys_userplatform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLAREDhideFields, which thesys_userplatform page had been authoring undeclared. Registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger: the change that declared the object form predates the gate that makes a breaking changeset state its ledger disposition, so nothing asked it what it had done about the ledger, and the sibling key on the same def —ui/RecordDetailsProps:layout, retired in a neighbouring change — carries a tombstone while this face carried none. ADR-0087. - Done when: Every
record:detailscomponent in authored metadata spellssectionsas an object array: each entry names the fields it renders (fields: [...]), optionally withlabel/name/columns.objectstack validatepasses and each detail page renders the same sections, in the same order, with the same fields as before the upgrade — a page whose sections silently render EMPTY is the signature of an ID list left in place. A section that existed only as an ID, with no field list recoverable from the page it belonged to, is a judgement for the author: name the fields it was meant to show, or delete the entry. Fields previously hidden by a convention outside the schema move onto the declaredhideFields.
- Why not automatic:
rest-server-openapi31-block-removed—restServer.openApi31→ (removed — no replacement key exists. Delete the key; for a real outbound webhook useWebhookfrom@objectstack/spec/automation. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)- Why not automatic: The
openApi31block (webhooks/callbacks/jsonSchemaDialect/pathItemReferences, typed byOpenApi31ExtensionsSchemawithOpenApiWebhookEventSchemaandCallbackSchemaunder it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server'snormalizeConfigforwards onlyapi/crud/metadata/batch/routes, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the declared-but-unconsumed shape an earlier audit found in the connector webhook and event enums one layer up). There is no behaviour to preserve and nothing stored to rewrite:RestServerConfigis plugin TS configuration (REST plugin constructor /plugin-hono-serverrestConfig), never asys_metadatashape — the stack tree'sapiblock declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not.strict()and a plain delete would strip it silently. - Done when: No
RestServerConfigvalue passed to the REST plugin (orplugin-hono-serverrestConfig) carriesopenApi31— a config that includes it now fails the parse with the retirement prescription instead of being silently stripped. No code importsOpenApi31Extensions(Schema),Callback(Schema)orOpenApiWebhookEvent(Schema)from@objectstack/spec/api(TS2305 after upgrade). The served /openapi.json is byte-identical before and after — the block never reached it.
- Why not automatic: The
runtime-httpserver-wrapper-retired—runtime.HttpServer (the exported delegating wrapper class)→ register anIHttpServerADAPTER INSTANCE directly as thehttp.serverservice —HonoHttpServeror whatever adapter the host already builds — instead of wrapping one- Why not automatic:
@objectstack/runtimeexported anHttpServerclass that took anIHttpServerin its constructor, declaredimplements IHttpServer, and forwarded only the contract's REQUIRED members (get/post/put/delete/patch/use/listen/close). It forwarded none of the OPTIONAL ones —getPort?(),getRawApp?(),setFallbackHandler?().packages/spec/src/contracts/http-server.tsinstructs consumers to feature-detect exactly those members withtypeof server.X === "function"and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrappedHonoHttpServerand registered the wrapper ashttp.serverwould answer 404 to every endpoint its metadata declared, becausesetFallbackHandler— the ONLY entry path for declarativeapis:endpoints since publish stopped refusing them and 17 began executing them — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a.parse(). That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, asstorage-service-list-retiredanddata-driver-find-stream-retired. Registered by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger, not by the original change: the wrapper's removal landed before the gate that makes a breaking changeset state its ledger disposition existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087. - Done when: No code constructs
new HttpServer(...)from@objectstack/runtime, and no import of the name resolves — the export is gone, so a typed caller fails to compile at the construction site. A host that was wrapping an adapter registers the ADAPTER INSTANCE ashttp.serverinstead, and then proves the capability came back:getPort()returns the real bound port afterlisten(0),getRawApp()returns the framework-native app, and a declarativeapis:endpoint declared in metadata answers its route rather than 404 — the last of which is the failure a wrapper produced silently. An adapter that genuinely needs to intercept calls implementsIHttpServerin full, forwarding the optional members too, rather than declaringimplementsand dropping them.
- Why not automatic:
sharing-execution-context-retired—@objectstack/spec: the exported typeSharingExecutionContext(contracts/sharing-service), and its re-export from @objectstack/plugin-sharing — the six-field context shape (userId/tenantId/positions/permissions/systemPermissions/isSystem) that sharing, approval and report enforcement signatures used to name→ExecutionContextfrom@objectstack/spec— the completeresolveAuthzContextenvelope the sharing, approval and report contracts have declared since they converged onto it. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value- Why not automatic: ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts —
ISharingService/ISharingRuleService,IApprovalService,IReportService— and it omitted four fields those gates need:accessible_org_ids(under thegrouptenancy posture this IS the Layer 0 wall, ADR-0105 D2),org_user_ids,posture(ADR-0095 D2) andtabPermissions. Its damage ran in the MIRROR direction of the share-link twin, which that same ruling moved onto the whole context: nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (const posture = (context as any).posturein plugin-approvals' privileged-override gate). One change converged the contracts, two more re-annotated the four implementations (sharing and audit, then approvals and reports), and this change removes the now-unreferenced declaration, the deletion that split had deferred. Why this needs a ledger entry despite nothing in-repo referencing it: it is theexport-field-meta-constraints-retired/hook-context-session-roles-retireddisposition — a PUBLISHED TypeScript surface with no spec schema, so there is noretiredKey()tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so noobjectstack migrate metatransform can reach it, and nosys_metadatarow carries it. ADR-0049 / ADR-0087. - Done when: No source of yours imports
SharingExecutionContextfrom@objectstack/specor@objectstack/plugin-sharing; each such import becomesExecutionContextfrom@objectstack/specand the build is green. tsc IS a sufficient detector here, unlike the optional-key retirements at this step: the name is gone outright, so every remaining reference is a hard resolution error rather than a silentundefined. ⚠️ Then check the direction tsc CANNOT see: widening an annotation never rejects a value, so an enforcement path that only ever received a hand-built six-field object still compiles and still under-adjudicates. Confirm each caller passes the context it was HANDED, unchanged, rather than a literal it assembled — and that any gate of yours readingposture,accessible_org_ids,org_user_idsortabPermissionsnow reads them declared, with noas anyin the path.
- Why not automatic: ADR-0049 enforce-or-remove, completing the maintainer's ruling of 2026-08-07 on the share-link context (enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts —
sharing-rule-recipient-reconcile—security.sharingRule.sharedWith.typegroup/guest, and owner-type rules (type: owner+ownedBy)→group→team(the enforced runtime vocabulary);guest→ delete the rule and expose the records through a public form or a share link;type: owner→ rewrite as atype: criteriarule.business_unitis newly authorable for the single-unit case- Why not automatic: The authoring
ShareRecipientTypeenum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-renamegroup, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (teamviasys_team/sys_team_member, andbusiness_unit). It also offeredguest, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename:group→teamis mechanical, butguestandtype: ownerhave no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; thequeuerecipient stays runtime-reserved and deliberately non-authorable (there is nosys_queueyet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one:sharing-recipient-role-to-positionis the ADR-0090 role → position rename andsharing-rule-access-level-full-to-editis the access-level vocabulary. The change came out of the metadata property liveness audit, which found security properties parsed but never enforced, and was registered late, by the stock reconciliation that compared the breaking changesets already on the v17 release train against this ledger. ADR-0078 / ADR-0090 D3 / ADR-0087. - Done when: No sharing rule names
grouporguest, and none carriestype: owner; stale definitions now FAIL parse with the valid options listed, so the sweep is "fix until nothing raises". ⚠️ Parsing clean is the weaker half — verify the SHARES, because a rule that was silently materialising nothing looked exactly like one that worked. For every rule that namedgroup, confirm thesys_teamit now resolves to has the membership you expected, and that records reach the people the rule was written for. Each formerguestrule needs an explicit decision about anonymous access — a public form grant or a share link, or knowingly no access at all — and each former owner-type rule needs acriteriapredicate that names the same population, checked against a representative record. Where a single business unit was meant, usebusiness_unit;unit_and_subordinatesis the subtree and grants strictly more.
- Why not automatic: The authoring
sort-node-direction-rejected—data.query.orderBy[].direction (SortNode)→order—orderBy: [{ field: "updated_at", order: "desc" }]. One word, same values (asc/desc)- Why not automatic:
SortNodeSchemawas a plainz.object, so zod's default.stripapplied and a sort node spelling its directiondirectionlost the key silently. Measured onmainbefore the change:SortNodeSchema.parse({ field: "updated_at", direction: "desc" })returned{ field: "updated_at", order: "asc" }— the key discarded andorderfalling back to itsascdefault, so the sort ran in the OPPOSITE direction and the request succeeded. Paired withlimit, which is how a caller asks for "the latest N", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for.directionis not a typo: it is the live vocabulary of a neighbouring contract,IReportService.orderBy, andplugin-auth/objectql-adapter.tsalready translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change:SortNodeSchemais now astrictObjectcarryingaliases: { direction: "order" }, andnormalizeSortNodesinmetadata-protocolrefuses{ field, direction }with400 INVALID_SORT. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridgesdirection→orderand a bare "unrecognized key" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a storeddirection: "asc"is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the stock reconciliation of the v17 train's breaking changesets: the in-code alias tombstone shipped with the change that closed both doors (maintainer ruling 2026-08-03), but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is whatspec-changes.json, the upgrade guide andos migrate metaproject to consumers. ADR-0049 / ADR-0087. - Done when: No authored
orderByentry — in metadata, in a saved view'ssort[], or in a REST / RPC request body — spells the keydirection. The upgrade's own verify loop is that the failure is now LOUD: a staledirectionraises a named parse error (or400 INVALID_SORTat ingress) quotingorder, so a sweep is "fix until nothing raises" rather than a search. ⚠️ Check the RESULTS, not just the parse: every list, report and paged query that carrieddirection: "desc"has been silently serving ASCENDING order and, wherever it was paired withlimit, a different set of rows. After the rename those pages change what they return — that is the defect being corrected, not a regression, and any downstream expectation baked against the old output has to be re-read rather than restored.
- Why not automatic:
spec-type-alias-input-suffix-retired—type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)→ the BARE name. ADR-0122 phase 2 moved the author state ontoX, which makesXInputa character-for-character synonym of it — the permanent synonym D3 forbids. Drop theInputsuffix:ConnectorInput->Connector. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves toXParsed, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE*Inputnames are NOT retired and need no edit:ExpressionInput,CronExpressionInput,TemplateExpressionInputandPredicateInputare the bare aliases of their own…InputSchema, andFormFieldInput,QueryInput,FieldInput,ObjectStackDefinitionInputandNavigationItemInputare composed (recursive orPartial-shaped) types no bare alias denotes.- Why not automatic: This entry exists for the reason
data-driver-find-stream-retired,storage-service-list-retiredandactor-user-roles-to-positionsexist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — anXInputalias never had a carrier key, never emitted a def, and no.parse()ever saw it. Measured and verified rather than assumed:json-schema/,json-schema.manifest/andauthorable-surface/are BYTE-IDENTICAL across this change, because those generators enumerate runtimez.ZodTypeexports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error saysConnectorInputdoes not exist, not thatConnectornow means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the gap ADR-0087 registration exists to close — thectx.userrolesalias was removed with no ledger entry, and that entry had to land separately. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED fromz.infertoz.input. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9 (its phase 2, which moved every bare name to the author state). - Done when: No source imports a name ending
Inputfrom@objectstack/specexcept the nine listed above:rg "\b\w+Input\b" --type tsover consumer code resolves only to those. A literal annotated with a bare spec type compiles while listing ONLY the keys the author means —const c: Connector = { name, label, type }type-checks, which it did not in 16.x — and a value read out ofXSchema.parse()annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should beXParsed.pnpm check:spec-parsed-aliasreports every bare alias asz.inputand refuses both a barez.inferalias and a reintroducedXInputsynonym.
- Why not automatic: This entry exists for the reason
storage-service-list-retired—contracts.IStorageService.list→ track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shapedlist(prefix, { cursor, limit })this entry reserved, restored since, once cloud proved to be the first-party caller this repository could not see- Why not automatic:
list(prefix)was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete.LocalStorageAdapter.listwas a single-levelreaddir, so a nested keya/b/cwas invisible underlist('a')(onlya/bcame back), and a subdirectory thatstatsucceeded on was pushed into the result as a file, yielding aStorageFileInfowhosesizeis a directory inode and which cannot be downloaded at all.S3StorageAdapter.listwas RECURSIVE (ListObjectsV2matches the whole key) and read neitherIsTruncatednorContinuationToken, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. The email plugin's large-attachment storage work was nearly that feature: it planned to drive attachment reclamation offlist(EMAIL_ATTACHMENT_KEY_PREFIX), found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was theSwappableStorageServicepass-through (which itself rejects when the active adapter has nolist), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, on the finding that measured the two dialects): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped,list(prefix, { cursor, limit }), with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a.parse(), so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, asdata-driver-find-stream-retired. ADR-0049 / ADR-0087. - Done when: No code calls
storage.list(...)on thefile-storageservice or on anyIStorageServicevalue. Code that needed "which files are under this prefix" reads the records it wrote —sys_file/ file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTSlistkeeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side:storage.list(...)no longer type-checks, and a PROXY typed againstIStorageServicethat forwards toinner.listis exactly such a caller — the one in@objectstack/service-storagegoes with the adapters' ownlistimplementations, removed in the retirement's implementation half. ⚠️ AMENDED 2026-08-09, under the maintainer's 2026-08-08 ruling on cloud's storage-enumeration callers (option B: restore enumeration upstream, correctly shaped, rather than hand-roll S3 pagination one repository over): the RESERVED route in the paragraph above was taken.listexists again on the contract, cursor-shaped —list(prefix, { cursor, limit })returning{ items, nextCursor }— because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argumentlist(prefix): StorageFileInfo[]is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other instorage-adapter-list.conformance.test.tsrather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none.
- Why not automatic:
tool-requires-confirmation-retired—ai.tool.requiresConfirmation→ put the operation behind an ACTION and setai.requiresConfirmation: truethere — the flag the platform confirmation CONTRACT is written against, and that contract is ENFORCED. An AI-facing call on an action declaring the flag must carry the confirmation memberconfirm: trueon the request and is REFUSED without it withACTION_CONFIRMATION_REQUIRED(428), the refusal naming the action and the exact member to set. A gate, not a queue: nothing is parked, and a refused call did not run — no record was read and none was written. ⚠ Two bounds: the enforced set is the doors that enforce the author'sai.exposedopt-in, today the action door reached from the MCPrun_actiontool, while REST/actionsis notai.exposed-gated and sits outside the gate; andconfirm: trueis an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved- Why not automatic:
ToolSchema.requiresConfirmationacceptedtrueand no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), notToolRegistry.execute, notPOST /ai/tools/:name/execute, and not the MCP bridge, which derivesdestructiveHintfrom a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss:action.ai.requiresConfirmationcarries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place.ToolSchemawas made.strict()in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping@objectstack/specis guaranteed to hit. Registered by the stock reconciliation of the v17 train's breaking changesets: theretiredKey()tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, and still stands inai/tool.zod.ts, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is whatspec-changes.json, the upgrade guide andos migrate metaproject to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087. - Done when: No tool definition carries
requiresConfirmation; the key now raises a located parse error naming the replacement, so the sweep is "fix until nothing raises". ⚠️ The load-bearing half is what happens NEXT, and no gate can check it for you: for every tool that carried the flag, decide whether that operation genuinely needs a human in the loop. If it does, move it behind an action carryingai.requiresConfirmation: true, which is what the platform confirmation contract gates on — and that gate is PERFORMED: invoking the operation over an AI-exposed door without the confirmation member is REFUSED withACTION_CONFIRMATION_REQUIRED(428) and nothing runs, so that call is a real check you can make rather than a destructive experiment. ⚠ Two bounds on what it proves: the enforced set is the doors that enforce the author'sai.exposedopt-in — today the action door reached from the MCPrun_actiontool — while REST/actionsis notai.exposed-gated and sits outside the gate, so an agent holding an API key on that route is still yours to put a human in front of; andconfirm: trueis an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved. The decision above is still the one this criterion asks you to make. If the operation does not need a human, delete the key knowingly. Deleting it without that decision leaves exactly the state the retirement exists to end: a destructive tool nobody is approving, now without even the false flag to show that somebody once meant to.
- Why not automatic:
ui-interaction-config-family-retired—ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)→ (removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor — the way inbound rate limiting came back, as a new key carrying only what its executor consumes — not by un-retiring a declaration)- Why not automatic: Five
@objectstack/spec/uimodules declared a full interaction-configuration vocabulary — 22z.objectsites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect:authorable-surface.jsonlisted 109 keys under these defs andcontent/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdxrendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author followingdnd.mdxand writing adnd:block onto a page component was rejected byPageComponentSchemafor an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module underpackages/spec/srcimported any of the five except theui/index.tsbarrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plusdefineStack'sObjectStackSchema(25 roots, 4742 nodes) reached none of the 21 named object shapes, whilePageSchema,WebhookSchemaandStateMachineSchemaall resolveddirectand a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero.parse()/.safeParse()in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (its types package deliberately dropped the spec/ui zod-validator re-exports and keeps type-only ones). The 2026-08-04 ruling retired the family — touch, drag-and-drop, keyboard and motion are renderer built-in behaviour and offline belongs to a sync engine, none of it per-page metadata — and weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes tostrictObjectand rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (the lesson of the datasource capability flags:readOnlywas precisely validated and read by nothing, while a shipped example called a datasource a read replica and wrote through it). Because there was no carrier key there is nothing to tombstone and nosys_metadatarow or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 asplugin-runtime-family-retired(the kernel plugin-runtime family) and theHttpServerConfigretirement (seven keys no runtime read and no authoring door reached, retired with their container). ⚠️ Not to be confused with the theme-token retirement (theme-driven typography is not a near-term capability, so nine token groups nothing consumed were retired), which retired the THEMEanimationblock — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049. - Done when: No code imports any of the 64 retired names from
@objectstack/specor@objectstack/spec/ui—TouchTargetConfig(Schema),GestureType(Schema),SwipeDirection(Schema),SwipeGestureConfig(Schema),PinchGestureConfig(Schema),LongPressGestureConfig(Schema),GestureConfig(Schema),TouchInteraction(Schema),TransitionPreset(Schema),EasingFunction(Schema),TransitionConfig(Schema),AnimationTrigger(Schema),ComponentAnimation(Schema),PageTransition(Schema),MotionConfig(Schema),DragHandle(Schema),DropEffect(Schema),DragConstraint(Schema),DropZone(Schema),DragItem(Schema),DndConfig(Schema),FocusTrapConfig(Schema),KeyboardShortcut(Schema),FocusManagement(Schema),KeyboardNavigationConfig(Schema),OfflineStrategy(Schema),ConflictResolution(Schema),SyncConfig(Schema),PersistStorage(Schema),EvictionPolicy(Schema),OfflineCacheConfig(Schema),OfflineConfig(Schema)— every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity inui/interaction-config-retirement.test.ts). No metadata document needs editing, because none could ever carry one of these blocks: a stack that parsed before parses byte-for-byte the same after. If you consumed the bareConflictResolutionfrom@objectstack/spec/uias a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's.@objectstack/spec/integration'sConnectorConflictResolution(connector sync) and@objectstack/spec/api'sConflictResolutionStrategy(route merge policy) are different concepts and are untouched.
- Why not automatic: Five
ui-notification-action-embed-config-retired—ui.notificationAction / ui.embedConfig→ (removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the survivingNotificationType/NotificationSeverity/NotificationPositionvocabulary; public access to a form is granted by the LIVEFormView.sharingblock (SharingConfig), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)- Why not automatic: Both shapes were published
@objectstack/spec/uivocabulary with NO AUTHORING DOOR. The v17 unknown-key strictness sweep, which measured each ui/ file for an authoring door before closing any shape, measured them three ways on 2026-08-03 and this retirement re-ran all three againstorigin/mainbefore removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema inpackages/spec/srcdeclared a key of either type (ui/notification.zod's only non-test importer was the barrel;ui/sharing.zod's were the barrel andui/view.zod.ts, which names its SIBLINGSharingConfigSchema), measured by resolving specifiers rather than substring-matching, because the repo holds twosharing.zodmodules and a substring test miscreditsstack.zod.tsto the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plusdefineStack'sObjectStackSchema, overbuild-schemas.ts's own walk including its derived-clone bridge, never reached either, whilePage/Action/DashboardWidget/WebhookandSharingConfigitself all resolvedroot-graphin the same run and an injected synthetic carrier flipped both; (3) PARSE — zero.parse()in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the shape of the unwired plugin sandboxing config removed before it, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takesEmbedConfigSchemain the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so nosys_metadatarow can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The sweep deliberately did NOT close them with.strict()— strictness is a property of a PARSE, and closing a shape nothing parses buys only "a precisely-validated dead slot, the more convincing lie" (the lesson of the datasource capability flags, whosereadOnlywas precisely validated and read by nothing) — and left the disposition to ADR-0049's enforce-or-remove, which came back REMOVE on 2026-08-04: a dead surface with no authoring door retires implementation-first, as three same-shape rulings that week had already decided. Each was orphaned by an earlier retirement one level up:NotificationActionlost its wrappers when the dual-source cleanup removed the./uicopies ofNotificationSchema/NotificationConfigSchema(the same names declared differently on other entry points) — that retirement's published "zero consumers" evidence was later falsified for objectui, which re-exported both names, and is corrected onui/notification.zod's tombstone; the removal itself stands — andEmbedConfiglost its key at 17.0.0 when the 2026-06 liveness audit retiredApp.embed(no iframe route ever read it) — that key still stands as aretiredKey()tombstone inapp.zod.ts, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file:ui/sharing.zodKEEPSSharingConfigSchema, a live door carried byFormViewSchema.sharingand read byrest-server.tsto mount the anonymous form routes, andui/notification.zodkeeps its three presentation enums. objectui consumedNotificationActionSchema.shape.variantas a VOCABULARY (never a parse) to pin its own hand-writtenNotificationActionButtoninterface — which is exactly why "has a consumer" never meant "has an authoring door" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049. - Done when: No code imports
NotificationActionSchema,NotificationAction,EmbedConfigSchemaorEmbedConfigfrom@objectstack/specor@objectstack/spec/ui— both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity innotification-embed-retirement.test.ts). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing:NotificationTypeSchema/NotificationSeveritySchema/NotificationPositionSchemaandSharingConfigSchemamust still be exported from./ui, and both modules must still load — a retirement that deleted either file would satisfy the absence half while destroying working surface. Nothing regresses at runtime, because nothing ever ran: no notification action was ever parsed from metadata and no iframe route ever read an embed config. Public form sharing is unaffected —FormView.sharingstill gates the anonymous endpoints onallowAnonymous+publicLink.
- Why not automatic: Both shapes were published
ui-widget-i18n-family-retired—ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)→ (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was:field.widgetis a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (RuntimeWidgetManifest/RuntimeWidgetSourcein@object-ui/types, renamed off the spec's names under objectui's rule that a symbol named like a spec export must import it or take a name of its own), which models different keys and never derived from these. For localisation: write the default-language string onlabel/description— the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVEsystem/translation.zod.tssurface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)- Why not automatic:
ui/widget.zod.tspublished a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — andui/i18n.zod.tspublished a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run onorigin/mainimmediately before the removal with their controls passing in the SAME run: (1) no module underpackages/spec/srcimportedwidget.zodat all, and the only imports ofi18n.zodanywhere nameI18nLabelSchema/AriaPropsSchema(both KEPT), so no schema declared a carrier key —field.widgetis az.string()naming a registered component and has never referencedWidgetManifest; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plusdefineStack'sObjectStackSchemareached none of them, whilePageSchema/ObjectListViewSchemaresolveddirectin the same run and a synthetic carrier flipped every one of them; (3) zero.parse()/.safeParse()in objectstack, objectui or cloud outside these files' own unit tests.NumberFormat/DateFormatDID have a carrier key (LocaleConfig.numberFormat/.dateFormat) but the carrier was itself doorless, so the subtree wasno doorrather thanno gateand goes whole — leaving the two leaves behind would strand exported schemas with no consumer, which an author reads as a capability.I18nObjectSchemawas additionally superseded by its own file-neighbour:I18nLabelSchema's documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface issystem/translation.zod.ts, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them tostrictObjectwas rejected earlier and explicitly, by the batch of the v17 unknown-key strictness sweep that measured this file as having no authoring door — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (the lesson of the datasource capability flags, whosereadOnlywas precisely validated and read by nothing). With no carrier key there is nothing to tombstone and nosys_metadatarow or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape asui-interaction-config-family-retired,plugin-runtime-family-retiredand theHttpServerConfigretirement. ⚠️WidgetManifest.performance's ownretiredKey()tombstone (left by the close-out sweep that removed the inertperformancekeys no renderer applied) is SUBSUMED here, the way the kernelactivationEventstombstone went with the removed plugin-runtime family: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired.FieldWidgetPropsSchemasurvives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared inauthorable-surface/orjson-schema.manifest/— itsonChangeis az.function()), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before that sweep batch measured: an objectui fix of 2026-08-03, made to follow the spec, renamed@object-ui/fields' validation slot onto the spec'serrorwith no alias, the form renderer began producing it, andpackages/fields/src/__tests__/spec-symbol-batch7.test.tspins the shape againstimport type { FieldWidgetProps } from '@objectstack/spec/ui'as an intentional tripwire. Re-verified on objectuiorigin/main2026-08-07. ADR-0049. - Done when: No code imports
WidgetManifest(Schema|Parsed),WidgetLifecycle(Schema),WidgetEvent(Schema|Parsed),WidgetProperty(Schema|Parsed),WidgetSource(Schema|Parsed),I18nObject(Schema),PluralRule(Schema),NumberFormat(Schema|Parsed),DateFormat(Schema)orLocaleConfig(Schema|Parsed)from@objectstack/specor@objectstack/spec/ui— every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity inui/widget-i18n-retirement.test.ts). No metadata document needs editing, because none could ever carry one of these shapes: a stack that parsed before parses byte-for-byte the same after, and afield.widget: "my_picker"string is untouched.FieldWidgetProps/FieldWidgetPropsSchema/FieldWidgetPropsParsed,I18nLabel(Schema)andAriaProps(Schema)all still resolve on@objectstack/spec/uiand are asserted to. ⚠️ objectui needs a companion PR in the same window:packages/types/src/__tests__/page-nav-misc-spec-parity.test.tsasserts the spec STILL ownsWidgetManifest/WidgetSource(it is the "a workaround should not outlive its reason" half of the rename tripwire objectui added when it stopped declaring symbols under names the spec owns, designed to go red exactly here), andpackages/types/src/widget.ts's "Renamed off the spec'sWidgetManifestname" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage.
- Why not automatic:
ups-delegated-from-column-retired—sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched→ nothing on this table — delete the key from any authoredsys_user_permission_setseed row (stackdataentries) or data-door write that still carries it. Delegation semantics live onsys_user_position, wheredelegated_fromremains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes "via delegation from X, until Y". A permission-set grant that needs a provenance note keepsreason(free text), which remains declared on both grant tables- Why not automatic: Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (
isDelegationWritereturns false for every other object, soassertSelfDelegationis unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 "delegation row needs a reason" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stackdataseed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare. - Done when: No authored stack seeds
delegated_fromon a sys_user_permission_set record, and no client write to that table carries the key. Concretely: (1) grep your stack sources for delegated_from next to sys_user_permission_set — delete the key from any seed row; a row that was recording genuine hand-over provenance should say it inreasoninstead, which the platform stores on both grant tables. (2) Boot and load your stack: a missed seed row fails loudly at insert with 400 INVALID_FIELD naming the column — that refusal is the enforced channel, not a silent drop. (3) If you meant actual delegation-of-duty, author it where it is enforced: a sys_user_position insert with delegated_from = the writer, a mandatory future valid_until within the ceiling, and a mandatory reason (ADR-0091 D3) — the delegated-admin gate then validates the whole shape at runtime.
- Why not automatic: Maintainer ruling 2026-08-18 on the finding that the delegation gate never reads this column on this object, ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (
view-filter-rule-value-shaped-by-operator—ui.ViewFilterRule value — the third key of a view filter rule, on every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, Page.filterBy, a related-list component filter and a lookup picker filter. It accepted any declared scalar or array for EVERY operator; the accepted shape is now decided by the rule operator — in / not_in require an array, between requires exactly two bounds, and every other operator is unchanged→ an ARRAY for in / not_in (a single value becomes a one-element list: value: "won" becomes value: ["won"]), and a two-element [min, max] array for between. The empty list [] stays legal for in / not_in and keeps its meaning. Nothing else moves: a scalar operator carrying an array, a string operator carrying a number, and a unary operator carrying an ignored value all still parse- Why not automatic: A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half:
assertListComparandShapes(@objectstack/objectql, filter-comparand-shape.ts) refuses a lowered{ stage: { $nin: "won" } }with a named 400 INVALID_FILTER, and before that it was a 500. The authoring surface stayed silent, so the failure was two-stage: the view published cleanly and only broke when someone opened it. That file names this very schema as the reachable authoring source of the defect. The tightening MIRRORS that gate exactly — three constraints, one for one — and deliberately goes no further, because an earlier fix already settled the opposite error (the ordering operators' comparand widened to the strings the platform itself produces): a schema stricter than the runtime "in ways the runtime deliberately allows" was the WRONG side and was widened to match. Soin: []is still accepted (a declared predicate both drivers implement),equals: ["a","b"]is still accepted (it lowers to a deep-equality comparand), andis_empty: ""is still accepted (the null predicates take their direction from the operator NAME — convertComparison ignores the value position, and the ObjectUI client deliberately sends a truthy placeholder there). ⚠️ Metadata AT REST is deliberately NOT rewritten, and there is no D2 conversion. A D2 entry replays a shape the platform once WROTE and renamed; this shape was never written by any first-party producer (every in / not_in rule in this repo, in objectui and in the cloud repo already carries an array — measured) and has never EXECUTED, since it 400s on first render today. Coercing it at load would be the platform guessing intent rather than replaying a rename, and it cannot guess honestly: value: "" would become the predicate [""] (a real filter on the empty string) rather than the "not filled in yet" a console row means, and between: 5 has no defensible second bound at all. The read path does not re-validate stored rows (applyConversionsToStoredItem never validates, by its own contract), so no stored view becomes unreadable; what changes is that RE-SAVING such a view is refused at the write gate namingvalue, instead of storing a filter that 400s. ADR-0049 / ADR-0078 / ADR-0112. - Done when: Grep your authored views, pages and related-list components for a filter rule whose operator is in, not_in or between (including the alias spellings nin / notIn / notin) and whose value is not an array of the right arity, then wrap or complete it.
os validate/os lintnow report each one by path with the operator, the received shape and the corrected shape, so the sweep is mechanical rather than by eye. Two checks are worth doing where it looks unnecessary: a rule readingoperator: "in", value: ""is an UNFINISHED row, not a filter — decide what it was meant to select rather than mechanically rewriting it to [""], which is a real and different predicate. And a view that already carried one of these shapes was never returning filtered rows: it answered 400 INVALID_FILTER on render, so re-check what the view is supposed to show rather than assuming the old result set was correct.
- Why not automatic: A publish-time gate catching up to a query-time one, not a new rule. An earlier fix closed the RUNTIME half:
view-management-protocol-retired—api.listViews / api.getView / api.createView / api.updateView / api.deleteView (the ViewProtocol interface and its ten Request/Response schemas in api/protocol.zod.ts — 10 defs, 25 exported names)→ the two view surfaces that are actually routed. For a view's STORED definition, the generic metadata methods withtype: 'view'—getMetaItem/getMetaItems/saveMetaItem/deleteMetaItem, served at/api/v1/meta/view/:name. For the RESOLVED render-time view,getUiView(GetUiViewRequest/GetUiViewResponse), served at/api/v1/ui/view/:object/:type. Neither is addressed by aviewId, which is the one thing the retired surface offered and the one thing nothing implemented- Why not automatic: A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (
packages/metadata-protocol/src/protocol.tsdeclares nolistViews/getView/createView/updateView/deleteView; its only view resolver isgetUiView), no route (packages/rest/src/rest-server.tsnever mentionsviewId, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the onlyViewProtocolmention outside its own file was the services checklist, which already recorded the five as declared-and-unrouted). The look-alike hits a bare-name grep turns up are all different contracts:metadata-manager.ts'sgetView(name: string)is another class, and objectui'sgetView(objectName, viewId)resolves throughclient.meta.getItem('view', …), i.e. the metadata route. What makes this worth a removal rather than a note is that the cost is already measured. A declared surface that is name-identical and semantics-adjacent to a real one is an attractive nuisance in every grep, and it mis-directed a decision once: The issue asking whatGET /ui/view/:object/:typeanswers AND its 2026-08-07 maintainer ruling both readGetViewResponseSchema(zero implementations) as the contract ofGET /ui/view/:object/:type, whose declared response isGetUiViewResponseSchema— one word apart, 250 lines up. That ruling's reasoning happened to survive the mix-up ("nobody can consume{object, view}successfully today" was true, though not for the stated reason), which is the luck this removal stops relying on. Route 3: none of the ten was a key on an authorable shape, nothing parsed them, so there is no tombstone and no D2 conversion — RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. If reading and writing ONE view by id becomes a real requirement it returns implementation-first. ADR-0049, ADR-0087, maintainer ruling 2026-08-07. - Done when: No source imports
ListViewsRequest(Schema),ListViewsResponse(Schema),GetViewRequest(Schema),GetViewResponse(Schema),CreateViewRequest(Schema),CreateViewResponse(Schema),UpdateViewRequest(Schema),UpdateViewResponse(Schema),DeleteViewRequest(Schema)orDeleteViewResponse(Schema)from@objectstack/spec/api, and no host declares aViewProtocolmember. Reading and writing views still works end to end through the surfaces that were always the live ones:GET /api/v1/meta/view/:namereturns the stored definition andGET /api/v1/ui/view/:object/:typereturns the resolved view, both unchanged by this removal.GetUiViewRequestSchema/GetUiViewResponseSchemastill resolve — they are the shapes that ruling meant.
- Why not automatic: A complete viewId-addressed CRUD surface — list (with a list/form filter), read, create, patch, delete — with none of the three things a protocol method needs. Measured on origin/main immediately before the removal: no implementation (
workflow-service-slot-retired—CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow→ the live mechanisms the slot only ever pointed at:state_machinevalidation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks +record_changeflows (service-automation) for record-triggered automation- Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted
/api/v1/workflow(DEFAULT_DISPATCHER_ROUTES, before it was retired as a stale list, named it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078. - Done when: No import of IWorkflowService, WorkflowProtocol or the Get/WorkflowState/Config/Transition types resolves; no code calls getService('workflow') or reads discovery
routes.workflow/services.workflow; record state machines, approvals and record-triggered automation go through the replacement mechanisms. Discovery output on a default boot is unchanged (the slot was always reported unavailable; now it is simply absent).
- Why not automatic: The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted
Machine-readable equivalents: spec-changes.json (shipped in @objectstack/spec and attached to each GitHub Release) and the structured output of objectstack migrate meta --json.