ObjectStackObjectStack

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 test

Mechanical 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)

ConversionSurfaceChangeLoad window
action-execute-to-targetaction.executeaction 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-requiredWhenfield.conditionalRequiredfield 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-skillsagent.toolsagent 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-editsharingRule.accessLevelsharing-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-aliasflow.node.config.objectNameCRUD 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-aliasesflow.node.notify.confignotify 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-liftflow.node.wait.waitEventConfigwait 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-liftflow.node.connector_action.connectorConfigconnector_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-aliasflow.node.map.config.flowNamemap 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-aliasflow.node.subflow.config.flowNamesubflow 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-aliasesflow.node.script.configscript 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-removedpermission.rowLevelSecurity.priorityRLS-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-removedtool.category / tool.permissions / tool.active / tool.builtIntool 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-removedapp.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.orderapp 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-removedapp.areas.visible / app.areas.requiredPermissionsnavigation-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-removedaction.shortcut / action.bulkEnabledaction 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-removedflow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeIdflow 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-removedview.list.responsive / view.list.performance / view.form.defaultSort / view.form.ariaview 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-removedview.list.striped / view.list.bordered / view.list.virtualScrollview 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-removedview.list.exportOptions / view.listViews.*.exportOptionslist-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-removeddashboard.aria / dashboard.performance / dashboard.widgets[].performancedashboard 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-removeddashboard.widgets[].responsivedashboard 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-removeddashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].ariadashboard 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-convergeddashboard.widgets[].compareTodashboard 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-removedagent.knowledgeagent 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-removedskill.triggerPhrasesskill 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-removedstack.api.requireAuthstack 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-outretired — migrate meta only
flow-node-wait-timeout-keys-removedflow.node.waitEventConfigwaitEventConfig 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 builtretired — migrate meta only
datasource-read-replicas-removeddatasource.readReplicasdatasource 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-removeddatasource.capabilitiesdatasource 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-removeddatasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermissiondatasource 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-removedmapping.extractQuery / mapping.errorPolicy / mapping.batchSizemapping 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-removedbook.translations / book.groups.translationsbook 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 liveretired — migrate meta only
job-id-removedjob.idjob 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-removedtranslation.validationMessagestranslation 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-aliasesdatasource.configdatasource 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-mongodbdatasource.driverdatasource 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 themlive — protocol 17 loader accepts the old shape
flow-node-script-branch-keys-removedflow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.scriptscript 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 logicretired — migrate meta only
retry-policy-convergedflow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplierretry 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 didlive — protocol 17 loader accepts the old shape
object-managed-by-system-to-system-dataobject.managedByobject 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-removedobject.enable.trash / object.enable.mruobject 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-removedhook.body.capabilities / action.body.capabilitiesscript-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-removeddataset.measures[].aggregatedataset 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-removedconnector.rateLimitConfigconnector 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-removedconnector.fieldMappings[].transform / externalLookup.fieldMappings[].transformfield-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-removedtheme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndextheme 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-aliaspage.component.page-header.descriptionpage-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-removedobject.indexes[].type / object.indexes[].partialobject 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-fieldpage.component.element:record_picker.displayFieldrecord-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-removedpage.component.element:record_picker.searchFields / page.component.element:record_picker.multiplerecord-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-childrenpage.component.page:card.bodypage: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-extrapage.component.element:button.action.paramsinline 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-stylepage.component.page:tabs.typepage: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-removedpage.component.page:header.icon / page.component.page:card.actionspage: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-removedpage.component.record:details.layoutrecord:details component prop 'layout' removed (the declared autocustom modes were never implemented; the renderer branches only on inline
app-hidden-to-unpublishedapp.hiddenstored 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-removedaction.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 via registerNodeExecutor/defineActionDescriptor) → nothing to re-declare — delete the key. Suspension is execute() RETURNING suspend: true, and permission to suspend is supportsPause: true on the same descriptor (with the resumeAuthority its pauses need)
    • Why not automatic: ADR-0049 enforce-or-remove. isAsync declared "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 capability supportsPause states, and the two diverged in exactly the way a duplicated declaration does: screen declared both, map and wait declared isAsync alongside supportsPause, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling — AutomationEngine now refuses a suspension whose type does not declare supportsPause: 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 and os migrate meta cannot reach it. The schema tombstones it via retiredKey() and descriptor authors delete the key themselves; that rejection (a tsc error at the authoring site, and a parse error inside defineActionDescriptor) is the channel a third-party plugin author actually meets. The EnhancedApiError.fieldErrors disposition, 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 returns suspend: true from execute() declares supportsPause: true on its descriptor together with a resumeAuthority, and its runs still pause and resume as before: the behaviour never depended on isAsync, so deleting the key changes no run. Authoring isAsync fails tsc at the descriptor literal and fails defineActionDescriptor() at runtime with the prescription, instead of parsing clean and being stripped.
  • 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, and ActionDescriptor.resumeAuthority used 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 with PERMISSION_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 generic wait, wait is 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 disposition data-driver-find-stream-retired, storage-service-list-retired and actor-user-roles-to-positions already 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, and check:resume-authority-declared for 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 no declares supportsPause but never declares resumeAuthority warning 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. ⚠️ supportsPause is no longer the declaration nothing enforced: an executor whose execute() returns suspend: true while leaving supportsPause false is still warned about by neither warning channel, but AutomationEngine.refuseUndeclaredSuspension now refuses that suspension at the one seam every suspension passes through — a guard-class failure no fault edge 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.
  • action-session-roles-to-positions — ui.actionSession.roles → ui.actionSession.positions (an action body reads ctx.session.positions)
    • Why not automatic: The MIRROR-IMAGE sibling of actor-user-roles-to-positions, and the reason both are in this step: the hook ctx.session carried roles declared-and-never-produced (removed outright), while the ACTION body's ctx.session carries it produced-and-really-populated. buildActionSession() (packages/runtime/src/action-execution.ts) copies ExecutionContext.positions into a key spelled roles — 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 as ActionSessionSchema, and phase 2 renames the key. positions is now the canonical key on that schema and roles a deprecated alias of it; the producer emits both for one deprecation window (the runtime half of the same ruling), after which roles is removed on the path the v16 session-alias removal already walked (the hook session's tenantId alias: 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 action ctx.session is constructed per dispatch and never persisted, so no sys_metadata row, example or template can carry the key — the openApi31 / activationEvents / hook-context-session-roles-retired shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose ScriptContext.session is still unknown. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated current_user.roles to 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. A retiredKey() 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.json and 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 is ctx.session.positions and observes the same array (the rename is a rename — the VALUE is ExecutionContext.positions on both sides, which the runtime pin action-session-shape-contract.test.ts asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was roles.includes('admin') as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to positions.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, roles is absent and a body still reading it sees undefined — which is why the read must be moved inside the window rather than at its close.
  • actor-user-roles-to-positions — action body / AI route: ctx.user.roles (req.user.roles) → ctx.user.positions (an AI route handler reads req.user.positions) — the same array, under the one spelling ADR-0090 D3 sanctions
    • Why not automatic: The THIRD face of the ADR-0090 roles → positions rename, and the only one whose surface the spec never declared. ActorUser (packages/runtime/src/security/actor-user.ts) is the ONE producer of the user envelope handed to an action body as ctx.user and to an AI route handler as req.user; it declared positions and roles side 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 sibling action-session-roles-to-positions: action-session-roles-to-positions governs ctx.session, a DIFFERENT object reached through the same ctx, and that one KEEPS its one-window dual-emit. Same word, same dispatch, two faces, two schedules — ctx.user.roles is absent in 17 while ctx.session.roles still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: ctx.user has no spec schema and never had one. It is a runtime TS interface, so unlike HookContext.session.roles (tombstoned on a deliberately non-strict HookContextSchema once it had no producer and no consumer left) and unlike ActionSessionSchema (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 no retiredKey() prescription that could reach anybody — nothing ever ran an ActorUser through 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.json and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the findStream (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 in packages/spec/src/contracts, this one only in packages/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 — an ActorUser is constructed per dispatch and never persisted, so no sys_metadata row, example or template can carry the key (the openApi31 / activationEvents / hook-context-session-roles-retired shape). 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 delegated current_user.roles to 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 against origin/main — repo-wide user.roles was 4 hits, all of them in the pins the removal flipped; the four ActorUser construction sites build server-side envelopes that never enter a response body; objectui's .roles reads belong to two unrelated producers (the better-auth session, and the /auth/me/permissions payload). The cloud repo 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.roles and no AI route handler reads req.user.roles; every such read is .positions and observes the SAME array — the value was ExecutionContext.positions on 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 was roles.includes('admin') as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to positions.includes('admin') — renaming that read migrates the defect rather than the code. Unlike ctx.session there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails tsc at the read while an untyped or sandboxed one silently sees undefined — 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 by undefined, which cannot tell a removed key from one left behind holding nothing — the runtime pin action-ctx-user-shape.test.ts asserts both halves that way.
  • aggregation-node-distinct-retired — data.query.aggregations[].distinct → the count_distinct aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to COUNT(DISTINCT field) on both SQL faces since it took the enforce leg of the 2026-08-07 ruling that kept it declared and retired array_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 QueryAST members no executor runs, the one that dispositioned every other data.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, while SqlDriver.aggregate, the Turso RemoteTransport.aggregate, driver-mongodb's buildAggregationStage, driver-memory's computeAggregate and service-analytics' AGGREGATE_SQL all 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: sum and avg only — count returned from its own branch before reaching the dedupe, count_distinct fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move min/max. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): count_distinct already covers the only spelling anyone has measured demand for, and lowering SUM(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 — QueryAST is the client SDK builder's output and the POST /data/:object/query body, 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 gave joins/cursor/distinct/windowFunctions, applied verbatim one level down. ADR-0049.
    • Done when: No caller sends distinct inside an aggregations[] 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 through EngineAggregateOptionsSchema, which reuses AggregationNodeSchema by reference — and POST /api/v1/data/:object/query answers 400 VALIDATION_FAILED with a fields[] entry at aggregations.<i>.distinct instead of serving a number. Authoring it is a tsc error at the call site. ⚠️ The observable NUMBERS change on exactly one path and that is the point of the change: a sum/avg that 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.
  • 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 where filter 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.
  • 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 format key 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.
  • api-runtime-create-withdrawn — PUT /api/v1/meta/api/{name} (runtime-authored api endpoints, draft and active alike) → Declare the endpoint as a stack artifact (**/*.api.ts, or defineStack({ apis })) and ship it through publishPackage
    • Why not automatic: The api registry entry declared allowRuntimeCreate: true and the runtime never honoured it. Measured on a real showcase boot: PUT /api/v1/meta/api/e8_backdoor answered 200 with {"success":true,…,"message":"Saved …"}, and the declared route then answered 404 forever — with NO [EndpointMatcher] … EXCLUDED line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to IMetadataService.matchEndpoint -> EndpointMatcher -> MetadataManager.listForIndex('api'), which reads the manager's registry plus its registered loaders (["filesystem","memory"] on dev/serve); a runtime write lands in sys_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 read sys_metadata re-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. allowRuntimeCreate is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a **/*.api.ts file 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 disposition BatchOptions.validateOnly takes. Consequently gateApiDraftsForPublish is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no api draft can exist for it to judge. Re-entry is recorded in the ruling: if the Studio metadata-coverage work promotes apis to 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 api item through the runtime metadata API. PUT /api/v1/meta/api/{name} answers 403 with code: "NOT_CREATABLE" and a body naming both flags (allowRuntimeCreate=false, allowOrgOverride=false) and the prescription Declare it in source (**/*.api.ts) and redeploy — in ?mode=draft as well as direct-active, because the gate runs before the draft/publish branch and does not read mode. ⚠️ Verify the artifact route is UNAFFECTED, which is the whole point of the change: a stack declaring apis: still compiles, still passes validateApiEndpointDeclarations at 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 sets OS_METADATA_WRITABLE=api, the same single escape hatch job / agent / capability use; 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. Any api rows already sitting in sys_metadata from before this change were never served either; they can be deleted (deleteMetaItem is deliberately not gated by this refusal, so repair stays possible).
  • 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.apiMethods enum 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, aggregate and search → list; history → get; and restore / purge map to NOTHING — they never derived, because enable.trash was retired with the other dead enable.* 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 naming history was granting read of one record's audit trail; rewritten to get it grants ordinary record reads, and an allowlist naming search becomes a grant of full list. 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.mjs scans, 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 the apiMethods allowlist) predates the gate that makes a breaking changeset state its ledger disposition. ADR-0087.
    • Done when: No authored enable.apiMethods array names a legacy value; objectstack validate passes. 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 where history became get or search became list, 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 / purge are 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.
  • 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 promised enabled defaults to false (SLA off) while the plugin-approvals sweep never read the key at all — any escalation block with a positive timeoutHours escalated, and with action: 'auto_approve' that silently approved requests their author had declared off the clock. The flip moves the default to true and, in the same change, the sweep starts honouring an explicit enabled: false. The feature-level switch is whether an escalation block exists at all; within a block carrying timeoutHours, escalation is on unless explicitly turned off. Deployed metadata that OMITS enabled does not change behaviour: it escalated before (the sweep ignored the key) and escalates after (the parse materializes true). Stored request snapshots written before the flip carry a MATERIALIZED enabled: 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's created_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 explicit enabled: false finally 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 enabled inside escalation still escalates on timeout (no metadata edit needed). A flow that writes enabled: false stops escalating for newly opened requests — verify one such request stays pending past its timeoutHours with no escalate audit row and no auto-decision. Requests opened BEFORE the upgrade keep their pre-upgrade behaviour (they escalate) regardless of the stored enabled bit. Clients that parse metadata through the published JSON Schema now materialize enabled: true where they materialized false; a client that needs the SLA off must write it explicitly.
  • 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 ordinary create / update rows 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. For export there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering sys_audit_log on 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 / logout on the auth session hooks, config_change from 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 every sys_audit_log writer in the repo — there are exactly two: plugin-audits generic hook writer, whose actionFor maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auths admin user-import. Neither has ever emitted export or permission_change. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways hook-body-crypto-hash-removed, dataset-measure-array-string-agg-removed and action-global-nav-location-removed already 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_log is a platform-owned, append-only object whose every field is readonly: 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 stored sys_metadata row; this surface is neither, so the disposition is the one BatchOptions.validateOnly and 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 (validateRecord skips readonly fields, 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_log on action = "export" or action = "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 over sys_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 a switchwith 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.
  • 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 to create / update / delete only. A consumer filtering sys_audit_log on 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 + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. restore is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because actionFor() in audit-writers.ts is typed 'create' | 'update' | 'delete' | null and 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: the writes_only list 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_log is a platform-owned, append-only object whose every field is readonly: 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 (validateRecord skips readonly fields), 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_log on action = "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 over sys_audit_log: a filter naming restore should 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): the restore arm is unreachable and should go, and a switch with 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 inserting sys_audit_log rows 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.
  • 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/config from 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 setting plugins.passkeys / plugins.magicLink flipped 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 behind passkeys, whereas magicLink's better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an AuthFeaturesConfig — 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.passkeys or features.magicLink off /api/v1/auth/config; a client that gated UI on either now treats the capability as absent rather than reading undefined as false by accident, and constructing an AuthFeaturesConfig with either key fails to parse with its own prescription instead of being silently stripped. Magic-link deployments keep working: plugins.magicLink still mounts /api/v1/auth/magic-link/send and /magic-link/verify, which a custom UI may call directly.
  • 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, fifteen viewsub-blocks,ViewItem, userFilters) — plus the view write-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. A view body 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 .strip discarded 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.optional is the opposite polarity of required; errorHandling.maxAttempts counts the first attempt where maxRetries counts the ones after it; a responsiveStyles bucket written on responsive is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; aria.live is real on exactly one renderer and ariaLabelledBy has nothing to rename to; finally on try_catch and context on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the view union had an arm that both stripped and required nothing, so it matched every object and saveMetaItem persisted 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 changesets unknown-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-close and view-union-identity-precondition, each carrying its own FROM → TO table in CHANGELOG.md. One batch promoted a key before closing its block: userFilters.allowAddTab was 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 validate passes 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 every aria block 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 stored view bodies, GET /api/v1/meta/diagnostics?type=view lists 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.
  • 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 validateOnly key 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.
  • batch-row-result-schema-shape — api.batchOperationResult — the per-row results entries of BatchUpdateResponse (POST /data/:object/batch, /updateMany, /deleteMany) → errors: ApiError[] (was error: string — read row.errors?.[0]?.message, branch on row.errors?.[0]?.code), data (was record), and index (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 exported BatchOperationResult type and the reference docs all said errors: ApiError[] / data / index, while the wire carried error: string / record and never sent index at all. A TypeScript consumer written against the published type compiled, validated and read undefined at 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 making deleteManyData and updateManyData honour atomic carried to those two endpoints, is structured in the same move: the ROLLED_BACK: / NOT_ATTEMPTED: message-string prefixes become registered ApiError.code values (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.error or row.record on a batch result row; failures are read from row.errors (message via errors[0].message, rollback state via errors[0].code — ROLLED_BACK / NOT_ATTEMPTED), records from row.data, and rows correlate to the request via row.index. Every row the three endpoints emit parses under BatchOperationResultSchema with those keys present.
  • client-delete-result-success — client.DeleteDataResult.deleted (the return of client.data.delete()) → success — r.deleted → r.success. Same call, same wire body, declared name
    • Why not automatic: DeleteDataResult carried the comment Spec: DeleteDataResponseSchema above a declaration that contradicted it: the interface declared deleted: boolean while DeleteDataResponseSchema declares { object, id, success }. deleted has 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-scoped client.project(id).data.delete() — are pure unwrapResponse / _unwrap passthroughs, 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, read undefined at 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 reading undefined, on every deployment and not just some, because the protocol path has always answered success. 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 on r.deleted has 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 write r.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 answering deleted: true to the declared success, on the producer side). No deprecated deleted?: boolean transition 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 .deleted off a client.data.delete() / client.project(id).data.delete() result; tsc names 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: every if (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 to r.success turns 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 on deleted was asserting on undefined and needs rewriting, not renaming.
  • connector-inline-authentication-publish-refused — connector.authentication on AUTHORED entries (defineStack connectors:, PUT /meta/connector/:name) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too → a catalog descriptor drops authentication (or sets { type: "none" }) and documents the auth scheme in description; a dispatchable instance declares provider and references its credential with auth: { type, credentialRef } (ADR-0097 §3). Runtime registerConnector calls are unaffected — the runtime shape still carries resolved secrets inline.
    • Why not automatic: A published connector row lands whole in sys_metadata, so an inline token / key / password / clientSecret is 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 a none descriptor or a provider-bound instance with a credentialRef — 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-none authentication; formerly inline credentials are reachable through credentialRef resolution and the connector still materializes.
  • 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 own filter
    • Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, { kind, dimension? }, with no offset concept 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 threw compareTo 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 IS previousYear by definition. Every other duration has NO faithful target: previousPeriod shifts by the length of whatever window the widget's filter resolves to, which equals 7d only 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's filter and compares with compareTo: { kind: 'previousPeriod' } (or 'previousYear'), and dimension is named wherever the selection dates more than one time dimension. objectstack validate passes, and each affected widget renders a <measure>__compare column over the window its author intended.
  • 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: findStream was 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: SqlDriver and InMemoryDriver both awaited find() 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 through buildFindOptions, so it hardcoded projection: { _id: 0 } and silently discarded query.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 through find(). The ~20 driver test doubles that existed only to satisfy a required method almost all threw not 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 through DriverInterfaceSchema.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 through find() with limit/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.
  • data-driver-query-omit-object — contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain → DriverQuery (Omit<QueryAST, "object">): delete the redundant object: 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 QueryAST that lists object as 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 smuggled query.object cannot 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 a where could not name the type, wrote as any, and switched off checking for where / orderBy / fields along with it — 20 such sites were measured in the downstream cloud codebase, and a $like the 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 a QueryAST VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring query: QueryAST keeps compiling under parameter bivariance. What an implementation may no longer do is READ query.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 seven IDataDriver hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is data-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 IDataDriver call site passes an inline literal carrying object: — driver.find("account", { object: "account", where: … }) becomes driver.find("account", { where: … }). tsc is 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 read query.object is rewritten to use the object-name argument instead — that read now yields undefined whenever 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.
  • data-engine-batch-retired — contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema → IObjectQLEngine.transaction(cb) for in-process multi-write atomicity; the metadata protocol's batchData with options.atomic: true for a batch over one object; POST {basePath}/batch on the wire
    • Why not automatic: batch? was declared on IDataEngine for as long as that contract existed and was never implemented by any engine: ObjectQL has no batch method and there is no other engine in the tree. It also had no caller — DataEngineRequest was 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 what transaction: false was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours getDefaultDriverName? / 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.requests nested 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 a batch property, 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 made transaction reachable through the contract and D4 made batchData's atomic honest, while the wire batch has always validated with CrossObjectBatchRequestSchema / BatchUpdateRequestSchema from api/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 parsed DataEngineBatchRequestSchema, so a retiredKey() prescription would have no one to reach; its three authorable-surface.json baseline lines and its json-schema.manifest.json entry 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 references DataEngineBatchRequest; in-process multi-write atomicity goes through IObjectQLEngine.transaction(cb), a batch over one object through batchData with options.atomic: true, and a cross-object batch over the wire through POST {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.
  • data-field-changed-event-retired — api.DataEventType 'data.field.changed' → the data.record.updated event, whose payload already carries the per-field detail: changes (the changed fields), plus before / after
    • Why not automatic: data.field.changed was declared in DataEventType and emitted by nothing — the engine's publishDataEvent sends data.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 surrounding switch still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). DataEventSchema could not have carried the semantics even if something had emitted it — the payload is record-shaped (recordId, changes, before, after) with no field / oldValue / newValue slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on data.record.updated as changes, 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 authorable WebhookTriggerType, 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-rule full retirement owd-full-alias-removed hit). The enforced channels are tsc, which fails any consumer still naming the value in a DataEventType position, 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 a data.record.updated event's changes map (with before / after for 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.
  • 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 into sys_secret, handle stored at external.credentialsRef), or a direct external.credentialsRef secrets-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 the sys_metadata write. There is no mechanical rewrite: moving the value requires ENCRYPTING it into a sys_secret row 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.authToken key; each affected datasource carries external.credentialsRef (or has its secret bound through the connection form) and still connects; no cleartext credential remains in any stored sys_metadata row or authored source.
  • datasource-config-placeholder-refused — connection-material string keys of the built-in driver configs — postgres/mysql/mongo url/host/database/username, postgres schema/applicationName, mongo authSourceand theoptionspassthrough (judged deep), tursourl/syncUrl/encryptionKey, sqlite/sqlite-wasm filename— values containing${…} placeholder syntax → the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into sys_secret, handle stored at external.credentialsRef), or a direct external.credentialsRef reference. For environment-driven connections, the runtime environment itself: OS_DATABASE_URL and 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 in sys_metadata and 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_URL and friends) or bind secrets via external.credentialsRef, and still connect; no unresolved placeholder remains in any stored sys_metadata row or authored source.
  • 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 bare user@host stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into sys_secret, handle stored at external.credentialsRef), or a direct external.credentialsRef secrets-store reference
    • Why not automatic: The inline-credential closure refused the credential KEYS, and building it measured that config.url still accepted the identical secret one syntax over — postgresql://user:password@host/db landed in sys_metadata cleartext exactly as config.password did, 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_URL and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry datasource-config-inline-credential-refused: moving the value requires ENCRYPTING it into a sys_secret row 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 carries external.credentialsRef (or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any stored sys_metadata row or authored source.
  • declarative-apis-endpoints-live — stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING) → the same declarations, re-read as LIVE HTTP routes: path moved under /api/v1/apps/<manifest.namespace>/<subpath>, and every entry that declares authRequired: false re-confirmed as an intentionally anonymous endpoint carrying rateLimit: { 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 — authRequired included — parsed green and gated nothing (which is why the maintainer's 2026-08-04 ruling refused a non-empty apis: 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 an apis: 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 is authRequired. Its schema default is true, so an omission is SAFE and needs no review; an EXPLICIT authRequired: false is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed rateLimit (enabled: true — the key defaults to false, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with ApiEndpoint — the AUTHOR state — so that omitting authRequired compiles: const e: ApiEndpoint = { name, path, method, type, target } is legal and is the safe shape this paragraph prescribes. ApiEndpointParsed is the POST-parse type (defaults materialized, ADR-0122), where authRequired is 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 is false is the one thing this entry is trying to avoid. Hold a parse RESULT with ApiEndpointParsed; write declarations as ApiEndpoint. Grep every apis: entry for authRequired: false before 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 explicit manifest.namespace with 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 declared path is /api/v1/apps/<your manifest.namespace>/<subpath> and the stack declares that manifest.namespace explicitly; (2) every entry declaring authRequired: false is one you INTEND to be reachable without a session, and each carries rateLimit: { enabled: true, windowMs, maxRequests } — entries that were not intended to be anonymous have the key removed so the safe default (true) applies; (3) objectstack validate passes, which also proves no endpoint declares a shape 17.x cannot execute (type: script / proxy, mapping transform, an object_operation missing objectParams, cacheTtl on a non-GET method, inputMapping on 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.
  • delete-by-id-before-hook-repoint-retired — a beforeDeletehandler on a BY-IDdelete()assigningctx.input.id a 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 or throw from 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() or delete() is now IMMUTABLE inside a before* 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 rebinding previous (the fix that first made a single-row delete bind previous at all), so afterDelete and the roll-up recompute saw the row actually deleted. It now refuses with HookTargetRebindError / ERR_HOOK_TARGET_REBIND, path: 'by-id', exactly as the update() twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.

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 beforeDelete handler assigns ctx.input.id anything but the id it arrived with — grep handler bodies for assignments into input.id and rewrite each into an explicit ctx.ql.delete() for the other row, a caller-side { multi: true, where: … }, or a throw. A delete-heavy smoke run completes with no HookTargetRebindError (ERR_HOOK_TARGET_REBIND, path: 'by-id', event: 'beforeDelete') — and any that does raise names its expectedId and observedId, 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.aggregate and RemoteTransport.aggregate each read two aliases the Query Protocol has never declared: query.aggregations || query.aggregate and agg.function || agg.func. "Never declared" is measured, not assumed — git log -S over data/query.zod.ts finds no commit that ever introduced either name, there is no retiredKey() tombstone and no alias-table entry for them (the file's only alias table is SortNode's direction → 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 to dashboard/page measures: aggregate IS the canonical key there and func IS 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 through QueryASTSchema.parse() on this path. The enforced channel is tsc at the call site, once the parameter is DriverQuery — 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, as data-driver-find-stream-retired (IDataDriver.findStream, removed with no tombstone because nothing parses a driver object), storage-service-list-retired (the zero-consumer IStorageService.list, whose two adapters answered differently and both incompletely) and actor-user-roles-to-positions (the ctx.user roles alias, 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 to DriverQuery last — because the reverse order yields red nobody can explain. ADR-0049 / ADR-0087.
    • Done when: No caller passes aggregate: to a driver's aggregate(), and no aggregation entry spells its function func:; both are written aggregations: / function:. An inline literal still using either old spelling no longer type-checks (TS2353 at the call site). An untyped JS caller that keeps writing aggregate: silently receives no aggregate column — the grouping still happens, the measure is simply absent — and one that keeps writing func: receives INVALID_QUERY / 400 naming the undeclared function, identically on the local driver and the Turso remote transport.
  • 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 are queryDateGranularity, autonumber and batchSchemaSync)
    • 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) left DriverCapabilities.streaming pointing 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 inherit syncSchemasBatch from 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 declared streaming: false while implementing findStream; InMemoryDriver declared streaming: true over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on driver.beginTransaction, aggregate pushdown on typeof driver.aggregate, schema sync on typeof driver.syncSchema, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — supports literals live in driver classes and DriverConfig.capabilities is plugin TS configuration, neither ever a sys_metadata shape (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 because DriverCapabilitiesSchema is 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. batchSchemaSync also 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 supports literal or DriverConfig.capabilities object authors any of the 31 retired bits — a driver class that still writes one fails tsc against IDataDriver.supports (the bit is never), 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.supports spread (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).
  • 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. distinct is not declared on IDataDriver, so neither the narrowing of IDataDriver's query parameters to DriverQuery nor the follow-through that brought five drivers' implementations in line ever reached it, and it kept filters?: any while its body said something far more specific — applyFilters(builder, filters) is handed the ARGUMENT ITSELF, never a .where off 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, because applyFilters emits 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 disposition data-driver-find-stream-retired, storage-service-list-retired, actor-user-roles-to-positions and driver-aggregate-undeclared-key-aliases-removed already 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: FilterCondition is 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 named object and where — and so is a FilterArray. Both reach distinct type-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.
  • 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 with expand ({ expand: { account: { object: '<target>', fields: ['name'] } } }), keeping the reference column itself in fields — 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 related id produced 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 reaching findData. A caller reaching engine.find() / engine.findOne() DIRECTLY passed through none of it, and that caller set was measured, not assumed: a flow get_record node's authored fields: ['name', 'account.name'] parses (GetRecordConfigSchema restricts nothing), travels verbatim into data.find(...), cleared the engine's head-only projection filter on its head segment (account IS a field), and reached the driver as a projection column — where SQL renders "account"."name" against a table that was never joined, the DB answers no such column, and the driver's unknown-column recovery ladder — there so an unknown column never reads as "no rows" — retries select('*'). 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.

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.findOne call site passes a dotted fields entry, no flow get_record config authors one, and no saved report's query.fields names one — grep flow definitions and report definitions for a fields entry containing a ., and rewrite each to expand (keeping the reference column projected) or to a denormalised stored column. Reads complete with no INVALID_FIELD whose 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 — a where/ filter naming aformula field — at BOTH doors: the REST ingress (assertFilterFieldsExist, covering everything that reaches findData) 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; summary and autonumber fields need NO action, because both get real maintained columns and filter correctly
    • Why not automatic: formula is 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 a where on a formula field 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 real ObjectQL with is_open a formula over the stored status column: where {is_open: true} and where {is_open: false} each returned 0 rows with NO error, while the controls where {status: 'open'} returned 4 rows and where {subtask_total: 5} (a summary, which HAS a column) returned 1 row.

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 formula field on any surface — grep your saved report definitions (sys_saved_report.query.filter), flow node config.filter, dashboard widget filters and view filters for a filtered field whose object declares it as a formula, and denormalise each onto a stored column written when the source changes. A summary / autonumber field needs no action: both have real maintained columns and filter correctly. Reads complete with no INVALID_FIELD naming a virtual formula field in a filter, at either door.
  • engine-find-formula-order-by-refused — engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a formula field — 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; a summary field 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 formula field alike (assertSortFieldsExist, 400 INVALID_SORT), which covers everything reaching findData: the list route, POST /data/:object/query, the export route and the RPC dispatcher. A caller reaching engine.find() / engine.findOne() DIRECTLY passed through none of it, and a formula ORDER BY there was dropped in silence. Measured on a real driver: asc and desc came 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.

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.findOne call site sorts by a formula field, and no saved report's query.orderBy names one — grep your report definitions for an orderBy field whose object declares it as a formula, and denormalise it onto a stored column written when the source changes. A summary / rollup field needs no action: it has a real maintained column and sorts correctly. Reads complete with no INVALID_SORT naming 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: findOne first, then insert or update on what you find)
    • Why not automatic: The upsert flag promised insert-if-absent on engine.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, yet ObjectQL.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.upsert to engine.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.
  • 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 say fields, and nothing ever emitted fieldErrors, 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 from error.fields, and constructing an EnhancedApiError with fieldErrors fails to parse with the rename prescription instead of silently losing the array.
  • 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 the ETL factory — 9 defs, 27 exported names) → (removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is ConnectorSchema.syncConfig (integration/connector.zod.ts), which is PARSED AND VALIDATED but NOT EXECUTED — a declared shape, not a running sync. AutomationEngine.registerConnector runs ConnectorSchema.parse and stores the parsed definition; nothing reads syncConfig back off it, and the key has no reader outside packages/spec at all — the same measurement that deleted syncConfig.schedule in @objectstack/spec 17 under ADR-0049, with the other cron-typed positions nothing reads. What the platform DOES execute on a connector is its actions: a flow's connector_action node 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 is shared/mapping.zod.ts, whose transform is applied row by row by the REST import path and recorded key by key in packages/spec/liveness/mapping.json; scheduling is system/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 an ETLPipeline. 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 no liveness/etl.json or pipeline.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. The etl string 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 for ETLPipeline.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.md named ETLPipeline as the recommended destination for authors displaced by the L1 retirement and listed ten transformation types with copyable examples down to script | 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-policy is SUBSUMED here, the way the activationEvents, 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 of retry.maxAttempts on 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. The maxAttempts retiredKey() tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a retry block 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 the ETL factory from @objectstack/spec/automation; tsc reports 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 recommends ETLPipeline as L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaring syncConfig and an import declaring mapping.transform both behave exactly as they did in 16.x.
  • export-axis-opt-in — security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key) → an explicit allowExport: true on 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) and action-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, allowExport unset 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 SAP S_GUI 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing allowExport: true wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing false would 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.0 admin_full_access and organization_admin carried the grant explicitly, ON A * WILDCARD, and protocol 18 REMOVES it (see admin-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_default deliberately 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 granting true grants export, and false is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: viewAllRecords / modifyAllRecords are "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. Check member_default explicitly — it is the set most likely to have been carrying export by inheritance.
  • export-field-meta-constraints-retired — @objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by buildFieldMetaMap, reached as PreparedImport.metaMapfromprepareImportRequest) → the object schema you already hold — read fields[name].required / .system / .readonly / .defaultValue / .min / .max / .minLength / .maxLength off the same ObjectSchema you passed to buildFieldMetaMap, 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 very schema its 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 asks DataProtocol.validateData for 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/rest itself, and all five in-repo dependents of @objectstack/rest: runtime, cli, verify, plugin-auth, plugin-dev) and the objectui sibling; plugin-auth's identity import forwards prepared.metaMap into runImport but reads only the presentation keys through coerceRow. Why this needs a ledger entry despite that sweep: it is the findStream / IStorageService.list / actor-user-roles-to-positions disposition — a published TS surface with NO spec schema, so there is no retiredKey() 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/rest 14.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 reading meta.required after the upgrade gets undefined with 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 / maxLength and 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 no objectstack migrate meta transform 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 / prepareImportRequest result. Grep your sources for .required / .hasDefault / .minLength / .maxLength / .min / .max / .system / .readonly on an ExportFieldMeta-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 or any-typed read compiles clean and silently becomes undefined — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note hasDefault has no one-to-one replacement key: it was the derived predicate defaultValue != null, mirroring the engine's applyFieldDefaults gate, so read fields[name].defaultValue and apply that same != null test yourself.
  • 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.ts is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is kernel/events/integrations.zod.ts's EventMessageQueueConfig (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_metadata door reaches them; accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. ExternalDataSourceSchema.authentication.config is a record of unknown whose own docblock example wrote "clientSecret": "..." inline, and MessageQueueConfigSchema.sasl.password was a required inline broker credential — the class of the sys_metadata cleartext-sink finding (cleartext-at-rest credential sinks), except that unlike its two measured surfaces (driver config and connector authentication) nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (object.external binds ObjectExternalBindingSchema — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (DatasourceSchema under identical exclusions) returning hits in the same run. The consumed MQ near-namesake kernel/EventMessageQueueConfig deliberately 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 or sys_metadata row for a D2 conversion to rewrite: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the ui/ 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. ⚠️ The data/ExternalFieldMapping:transform tombstone 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 base shared/FieldMapping tombstone and the integration/ConnectorFieldMapping spelling are untouched and still reject transform with 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-level sys_metadata write-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) or DeadLetterQueue(Schema|Parsed) from @objectstack/spec, @objectstack/spec/data or @objectstack/spec/system — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in data/external-lookup-retirement.test.ts and system/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.external and kernel/DeadLetterQueueEntry survive unchanged.
  • field-runtime-create-withdrawn — PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone field items) → Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in fields — or declare it in the object source (**/*.object.ts) and redeploy
    • Why not automatic: The field registry entry declared allowRuntimeCreate: true and 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_probe answered 200 with {"success":true,"state":"active","message":"Saved field …"}, the row persisted, and GET /api/v1/meta/object/showcase_task then 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 that field is the ONE declared type with no standalone existence: fields are authored inside the object (ObjectSchema.fields), a field write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — applyRegistryWriteThrough routes only type === 'object', and filePatterns (**/*.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, ~20 gate.fields call sites, physical schema/migrations, and cold boot via loadMetaFromDb); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the api withdrawal'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: object keeps allowRuntimeCreate: 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. allowRuntimeCreate is a PLATFORM registry value, not an authorable one, and no authored source changes — an **/*.object.ts file 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 disposition api and BatchOptions.validateOnly take. ADR-0049 / ADR-0087.
    • Done when: No caller creates a standalone field item through the runtime metadata API. PUT /api/v1/meta/field/{object}.{name} answers 403 with code: "NOT_CREATABLE" and a body naming both flags (allowRuntimeCreate=false, allowOrgOverride=false) and the prescription PUT /api/v1/meta/object/:object with the new field in fields``. The plural spelling PUT /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 in fields still answers 200, and GET /api/v1/meta/object/{name} READS THE NEW FIELD BACK (assert on body.data.item.fields, not body.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 403 NOT_OVERRIDABLE, a different gate for a different question — making field OVERRIDES legal was never part of this decision. DISPOSITION OF EXISTING ROWS: field rows already written through the retired channel stay in sys_metadata and 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: deleteMetaItem is deliberately NOT gated by this refusal, so repair stays possible. An operator who needs the write door back on one deployment sets OS_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.
  • 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-removed and driver-sql-distinct-bare-filter-typed, this entry records a LENIENCY being withdrawn rather than a declared surface: $regex was never in FILTER_OPERATORS and never a key on StringOperatorSchema. That is measured, not assumed — git log -S'$regex' over packages/spec/src returns only doc comments describing how $contains LOWERS to MongoDB (Contains substring - SQL: LIKE %?% | MongoDB: $regex), plus the retirement's own contract-half change, which added the name solely as RETIRED_FILTER_OPERATORS prescription 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. FilterConditionSchema is 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 — a retiredKey() 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-sql and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so a.b matched only the literal a.b and the regex was silently never a regex), driver-memory and objectql's having ran it as a real RegExp (so the same filter also matched axb, and an INVALID pattern was caught and answered false — zero rows, in silence), and driver-mongodb refused it with a bare Error carrying no code and no status. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in semantic rather than among the mechanical transforms: rewriting $regex to $icontains is 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 $regex loudly and add $icontains, rather than make five backends agree on one regex dialect), not just the driver one: the contract half (the $icontains declaration, the $contains family pinned case-sensitive, and the RETIRED_FILTER_OPERATORS prescriptions) 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 where spells $regex or $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 $contains when 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 on driver-memory, driver-mongodb or objectql having, 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 $regex or $options is answered INVALID_FILTER / 400 with a message naming the replacement, on every backend.
  • 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's maxRetries ?? 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 exactly strategy: '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 declares maxRetries >= 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 say strategy: 'fail'. No flow fails to register with the maxRetries prescription.
  • hook-context-session-roles-retired — data.hookContext.session.roles → (removed — gate on session.userId / session.isSystem; for PRIVILEGE ask the security service, which reads permissions / 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's buildSession() builds the session field by field and has never written roles, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read hookContext?.session?.userId; objectui's roles are the /auth/me user payload, a different surface; an ACTION body's ctx.session is a different untyped object that does carry roles, 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 no sys_metadata row, example or template can carry the key and there is no source for the D2 chain to rewrite — the openApi31 / activationEvents shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (HookContextSchema is 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 uses ctx.session.userId / ctx.session.isSystem, and privilege comes from the security service (permissions / positions / posture). Constructing a HookContext session with roles fails tsc (the input type is never) and fails HookContextSchema.parse with 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.
  • hook-register-empty-object-target-refused — engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose excludeObjectscancels itsobject entirely → name the object(s) — object: 'account' / object: ['account', 'contact'] — or, for a global hook, object: '*' or no object key at all; for a cancelled scope, widen object or drop the overlapping names from excludeObjects
    • 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 and hook-binder.ts's normalizeObjects. 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 later excludeObjects face (a hook global except for the objects it names) then brought a fourth shape reached by arithmetic rather than by one bad name: an object list 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.

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 registerHook call site passes an empty object target, and none passes an excludeObjects list covering every name in its object list. Every record-change flow start node declares a non-blank config.objectName, or omits the key if the flow is genuinely meant to fire on every object. Boot completes with no "[ObjectQL] Hook ... declares an empty object target" 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 is http_requests_total{status=~"5.."} — the TRANSPORT emits that family through the IHttpServer.afterResponse seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the errorReporter (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. SEMCONV published http_request_errors_total as part of a stable namespace declared "so hosts can wire alerts/dashboards against it", but the only emitter was @objectstack/runtime's instrumentRouteHandler, applied only by the dispatcher's own route Proxy — so the series never saw auth's getRawApp() mount, the REST data API via RouteManager, 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: HttpResponseObservation carries {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 through errorResponseBase, which sets a status and does not re-throw, so the old counter MISSED those, while its catch incremented unconditionally, so a thrown 4xx WAS counted as an error. And http_requests_total already carries a status label, 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, as runtime-httpserver-wrapper-retired and enhanced-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 to http_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 the errorReporter, not a counter — wire an APM adapter and assert one synthetic 5xx throw arrives. In code, SEMCONV.httpRequestErrorsTotal and RUNTIME_METRICS.httpRequestErrorsTotal no longer resolve (tsc reports TS2339 at any surviving read) and no metrics.counter call names the string.
  • 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 is system/metrics.zod.ts and system/logging.zod.ts (plus OS_SERVER_TIMING for timings), and liveness is the /health endpoint. 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 on defineStack({ 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 to packages/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 or sys_metadata row 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, the ui/ 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, ServerStatus or ServerStatusSchema from @objectstack/spec/system — a grep over consumer code resolves none of them, and tsc reports TS2724/TS2305 on any that survives. The route-registration half of the same module still resolves (RouteHandlerMetadataSchema, MiddlewareType, MiddlewareConfigSchema, MiddlewareConfig), and StackServerConfigSchema — the one authorable server surface — is untouched: a stack declaring server: { trustProxy, security } parses exactly as it did in 16.x.
  • 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-flip and this major's action-descriptor-resume-authority-default-flip are: 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 with body?.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 to CreateImportJobRequestSchema is the declarative ImportJobApiContracts catalog 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 materialised runAutomations: false from 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 disposition notification-list-cursor-retired takes for the sibling default on this major, and batch-options-validate-only-retired before 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 — whose from/to fingerprints 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: false explicitly, 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 through ImportRequestSchema (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 omits runAutomations fired triggers before this change and fires them after, and runAutomations: false turns them off before and after. Nothing starts being refused — the route never validated this body against the schema and does not begin to. dryRun is 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.
  • 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: maxRetries is capped at 10 and backoffMultiplier floored at 1. Neither has a lossless rewrite. Clamping maxRetries: 20 to 10 would halve a retry budget its author chose, and a backoffMultiplier below 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 retryPolicy parses: no maxRetries above 10 and no backoffMultiplier below 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.
  • 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 larger limit — the route answers the newest N notifications and has no page 2. There is no replacement for cursor, 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 removed limit default, 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 unreadCount really count the whole inbox). cursor was declared on the request and on the response and honoured on neither: the dispatcher domain reads read / type / limit and 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 is data.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. The limit default 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). So cursor is retiredKey() on both halves, typed never for 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 stored sys_metadata row, and these two shapes are HTTP-only — nobody authors a ListNotificationsRequest and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition BatchOptions.validateOnly (a declared dry-run that wrote for real) and the AnalyticsQueryRequest envelope keys already take in this major. The limit default 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), whose from/to fingerprints are re-derived on every build. ADR-0049 / ADR-0078.
    • Done when: No caller sends cursor to GET /api/v1/notifications and no SDK call site passes it: client.notifications.list({ cursor }) is a tsc error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading response.cursor no longer type-checks either, and always answered undefined before. ⚠️ 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. unreadCount is 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 omitted limit receives the same 50 rows it always received.
  • package-uninstall-explicit-all-tenants — protocol.deletePackage({ packageId }) with no organizationId (and its transport, DELETE /api/v1/packages/:id) → explicit allTenants: true for a cross-tenant uninstall, or an organizationId to 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 organizationId nor allTenants: true answers 400 TENANT_SCOPE_REQUIRED and 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: resolveActiveOrganizationId is 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 disposition rest-requireauth-default-flip (protocol 12) takes for its own default flip.
    • Done when: Every caller of deletePackage states its tenant scope. A caller that intends an environment-wide uninstall passes allTenants: true; a caller that intends a scoped one passes organizationId; no caller passes both. An explicit allTenants: false is treated as undeclared and refused, since it is not an affirmative request for cross-tenant semantics. Verify the refusal is not merely absorbed: a 400 TENANT_SCOPE_REQUIRED reaching 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 an organizationId still removes that org's rows AND the environment-wide (organization_id IS NULL) rows, exactly as the orphaned-row repair left it.
  • 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 activationEvents keys — and the ActivationEventSchema trigger 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 writing activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }] expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — StudioPluginManifest is TS configuration parsed by defineStudioPlugin (a root schema, never part of a stack tree) and DynamicLoadRequest is a runtime request shape with no caller — so no sys_metadata row 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 via retiredKey() (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 aliases activation / events / onActivate), and the orphaned ActivationEventSchema / ActivationEvent exports are removed from ./kernel and ./studio with 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 whole DynamicLoadRequest shape — and the rest of the plugin-runtime family with it — was removed, which took this key's retiredKey() tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a DynamicLoadRequest to author the key INTO, so the prescription an author needs is no longer "delete this key" but "this request shape does not exist" (see plugin-runtime-family-retired). The studio half of this entry is unaffected and still enforced by the strict manifest parse.
    • Done when: No defineStudioPlugin input authors activationEvents — 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 no DynamicLoadRequest type or schema left to author it into at all. No code imports ActivationEventSchema / ActivationEvent from @objectstack/spec/kernel or @objectstack/spec/studio (TS2305 after upgrade). Runtime behaviour is byte-identical: plugins loaded eagerly before and after.
  • 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: defineStack registers them and the kernel runs init then start in an order topologically resolved from each composed plugin's own dependencies / optionalDependencies (resolvePluginOrder in packages/core/src/plugin-order.ts). For the isolation loading.sandboxing appeared 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 the node tier 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/spec itself: this module's own declaration, its own unit tests, the Manifest.loading embed and the generated artifacts. manifest.loading.* had zero readers in packages/core, packages/runtime and packages/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 is sandboxing: it declared process / vm / iframe / web-worker isolation, IPC transports and an allowedServices ACL, 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 dead PluginHotReloadSchema while the only implementation body, HotReloadManager (packages/core/src/hot-reload.ts), reads a different vocabulary — HotReloadConfigSchema in plugin-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 and applyConversionsToStoredItem maps a metadata type onto one of its collections. A package manifest is neither — PLURAL_TO_SINGULAR has no packages / plugins entry, 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.json and no stored package manifest carries a loading key. The enforced channel is the one place a manifest is parsed with an author present: os plugin build runs ManifestSchema.safeParse and exits non-zero, printing the tombstone prescription, so a manifest still declaring loading fails its build rather than shipping. TypeScript authors get it earlier still — loading is typed never, so assigning it is a tsc error. ⚠️ 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 carries loading keeps working — the registry's validate() 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.
  • 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: defineStack registers 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 a DynamicPluginResult. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads DynamicLoadRequestSchema in 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. experimental was 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 no sys_metadata row 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 of plugin-activation-events-retired: that tombstone goes with the shape that carried it. ADR-0049.
    • Done when: No code imports DynamicLoadRequestSchema, DynamicUnloadRequestSchema, DynamicPluginResultSchema, PluginSourceSchema, DynamicPluginOperationSchema or any of their type aliases (DynamicLoadRequest, DynamicUnloadRequest, DynamicPluginResult, PluginSource, DynamicPluginOperation, DynamicLoadRequestInput, DynamicUnloadRequestInput) from @objectstack/spec or @objectstack/spec/kernel — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in plugin-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 through defineStack is unchanged.
  • 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 authored sys_position seed row (stack data entries) 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_set rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position name at request time. A value that was recording intent as documentation belongs in description, 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 / name to 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 consults sys_position_permission_set rows plus the position name, 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 declared permissions, 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 for permissions, 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 permissions on 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 in description. (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_set rows, 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.
  • query-array-string-agg-retired — data.query.aggregations[].function ('array_agg' / 'string_agg') → an ordinary fields query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: count_distinct stays 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. QueryAST is never stored in stack metadata — it is the client SDK builder's output and the POST /data/:object/query body — 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.mapAggregateFunc and the Turso RemoteTransport.aggregate compile 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 on driver-mongodb and 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_distinct was 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_agg or string_agg in aggregations[].function; list-style roll-ups are assembled by the caller from an ordinary fields query, 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 a tsc error at the call site; count_distinct continues to parse and is unaffected.
  • query-cursor-retired — data.query.cursor → a where predicate on the sort key — where: { created_at: { $gt: last.created_at } } with the matching orderBy (the documented manual-keyset pattern)
    • Why not automatic: The cursor key 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-built Record<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 cursor and no SDK call site uses QueryBuilder.cursor(); deep pagination expresses the keyset as a where predicate on the sort key. A query still carrying cursor fails to parse with the removal prescription, and authoring it is a tsc error.
  • query-distinct-retired — data.query.distinct → groupBy for unique combinations; the count_distinct aggregation for deduplicated counts; the SQL/memory drivers' distinct(object, field) door for one column's values
    • Why not automatic: The distinct flag 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 degraded total/hasMore to 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 — total is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078.
    • Done when: No caller sends distinct and no SDK call site uses QueryBuilder.distinct(); deduplication goes through groupBy / 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 real total for queries that used to send it.
  • 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 dotted fields path 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 FieldNode union declared a nested-select object form { field, fields, alias } that was inert end to end: no producer emitted it, and no consumer read .fields or .alias — objectql's formula projection and known-field filters, driver-sql's select() and driver-memory's projection all treat the list as string[], driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is expand, which the engine resolves via batch $in queries. This is a REQUEST surface — QueryAST is 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 to z.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 through expand, with the foreign-key column retained in the projection so expansion has something to resolve. A fields entry 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]".
  • 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 dotted fields path 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 joins array was declared-but-inert: no engine or driver read query.joins anywhere 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 $in queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned JoinNode/JoinType/JoinStrategy cluster goes with the key. A REQUEST surface — QueryAST is 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 through expand, with the foreign-key column retained in the projection so expansion has something to resolve. A query that still carries joins fails to parse with the removal prescription (even as an empty array), and authoring it is a tsc error at the call site.
  • query-window-functions-retired — data.query.windowFunctions → aggregations + groupBy for request-level analytics; SqlDriver.findWithWindowFunctions(object, query) for embedders on a SQL datasource
    • Why not automatic: The windowFunctions array 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 behind SqlDriver.findWithWindowFunctions(), a driver-level door that is not on the IDataDriver contract and whose flat input shape ({ function, alias, partitionBy?, orderBy? }) the spec vocabulary never matched — WindowFunctionNodeSchema declared field/over/frame members 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 windowFunctions in a query; request-level analytics use aggregations + groupBy, and embedders needing OVER-clause SQL call the SQL driver's findWithWindowFunctions door directly. A query that still carries the key fails to parse with the removal prescription naming that door.
  • record-details-sections-object-form — ui.RecordDetailsProps.sections (the record:details page component) → an OBJECT array — sections: [{ label, columns, fields: [...] }] — replacing the string-ID list; label gives the heading, columns its grid width, name makes the heading translatable, and the new sibling hideFields omits named fields from the body
    • Why not automatic: record:details declared sections as 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 named overview was 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's RecordDetailsRenderer maps 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' RecordDetailsComponentProps mirror already declared Array<{ name?, label?, fields, ... }>; the Studio block designer can only author { label, columns, fields }; packages/lint has modelled it as nestedSections all along; and every page in this repo — three showcase pages plus the sys_user platform 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 DECLARED hideFields, which the sys_user platform 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:details component in authored metadata spells sections as an object array: each entry names the fields it renders (fields: [...]), optionally with label / name / columns. objectstack validate passes 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 declared hideFields.
  • rest-server-openapi31-block-removed — restServer.openApi31 → (removed — no replacement key exists. Delete the key; for a real outbound webhook use Webhook from @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 openApi31 block (webhooks / callbacks / jsonSchemaDialect / pathItemReferences, typed by OpenApi31ExtensionsSchema with OpenApiWebhookEventSchema and CallbackSchema under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's normalizeConfig forwards only api/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: RestServerConfig is plugin TS configuration (REST plugin constructor / plugin-hono-server restConfig), never a sys_metadata shape — the stack tree's api block 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 RestServerConfig value passed to the REST plugin (or plugin-hono-server restConfig) carries openApi31 — a config that includes it now fails the parse with the retirement prescription instead of being silently stripped. No code imports OpenApi31Extensions(Schema), Callback(Schema) or OpenApiWebhookEvent(Schema) from @objectstack/spec/api (TS2305 after upgrade). The served /openapi.json is byte-identical before and after — the block never reached it.
  • runtime-httpserver-wrapper-retired — runtime.HttpServer (the exported delegating wrapper class) → register an IHttpServer ADAPTER INSTANCE directly as the http.server service — HonoHttpServer or whatever adapter the host already builds — instead of wrapping one
    • Why not automatic: @objectstack/runtime exported an HttpServer class that took an IHttpServer in its constructor, declared implements 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.ts instructs consumers to feature-detect exactly those members with typeof 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 wrapped HonoHttpServer and registered the wrapper as http.server would answer 404 to every endpoint its metadata declared, because setFallbackHandler — the ONLY entry path for declarative apis: 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, as storage-service-list-retired and data-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 as http.server instead, and then proves the capability came back: getPort() returns the real bound port after listen(0), getRawApp() returns the framework-native app, and a declarative apis: 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 implements IHttpServer in full, forwarding the optional members too, rather than declaring implements and dropping them.
  • sharing-execution-context-retired — @objectstack/spec: the exported type SharingExecutionContext (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 → ExecutionContext from @objectstack/spec — the complete resolveAuthzContext envelope 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 the group tenancy posture this IS the Layer 0 wall, ADR-0105 D2), org_user_ids, posture (ADR-0095 D2) and tabPermissions. 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).posture in 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 the export-field-meta-constraints-retired / hook-context-session-roles-retired disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no retiredKey() 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 no objectstack migrate meta transform can reach it, and no sys_metadata row carries it. ADR-0049 / ADR-0087.
    • Done when: No source of yours imports SharingExecutionContext from @objectstack/spec or @objectstack/plugin-sharing; each such import becomes ExecutionContext from @objectstack/spec and 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 silent undefined. ⚠️ 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 reading posture, accessible_org_ids, org_user_ids or tabPermissions now reads them declared, with no as any in the path.
  • sharing-rule-recipient-reconcile — security.sharingRule.sharedWith.type group/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 a type: criteria rule. business_unit is newly authorable for the single-unit case
    • Why not automatic: The authoring ShareRecipientType enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename group, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (team via sys_team / sys_team_member, and business_unit). It also offered guest, 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 → team is mechanical, but guest and type: owner have 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; the queue recipient stays runtime-reserved and deliberately non-authorable (there is no sys_queue yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: sharing-recipient-role-to-position is the ADR-0090 role → position rename and sharing-rule-access-level-full-to-edit is 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 group or guest, and none carries type: 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 named group, confirm the sys_team it now resolves to has the membership you expected, and that records reach the people the rule was written for. Each former guest rule 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 a criteria predicate that names the same population, checked against a representative record. Where a single business unit was meant, use business_unit; unit_and_subordinates is the subtree and grants strictly more.
  • 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: SortNodeSchema was a plain z.object, so zod's default .strip applied and a sort node spelling its direction direction lost the key silently. Measured on main before the change: SortNodeSchema.parse({ field: "updated_at", direction: "desc" }) returned { field: "updated_at", order: "asc" } — the key discarded and order falling back to its asc default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with limit, 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. direction is not a typo: it is the live vocabulary of a neighbouring contract, IReportService.orderBy, and plugin-auth/objectql-adapter.ts already 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: SortNodeSchema is now a strictObject carrying aliases: { direction: "order" }, and normalizeSortNodes in metadata-protocol refuses { field, direction } with 400 INVALID_SORT. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges direction → order and 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 stored direction: "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 what spec-changes.json, the upgrade guide and os migrate meta project to consumers. ADR-0049 / ADR-0087.
    • Done when: No authored orderBy entry — in metadata, in a saved view's sort[], or in a REST / RPC request body — spells the key direction. The upgrade's own verify loop is that the failure is now LOUD: a stale direction raises a named parse error (or 400 INVALID_SORT at ingress) quoting order, 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 carried direction: "desc" has been silently serving ASCENDING order and, wherever it was paired with limit, 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.
  • 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 onto X, which makes XInput a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the Input suffix: ConnectorInput -> Connector. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to XParsed, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE *Input names are NOT retired and need no edit: ExpressionInput, CronExpressionInput, TemplateExpressionInput and PredicateInput are the bare aliases of their own …InputSchema, and FormFieldInput, QueryInput, FieldInput, ObjectStackDefinitionInput and NavigationItemInput are composed (recursive or Partial-shaped) types no bare alias denotes.
    • Why not automatic: This entry exists for the reason data-driver-find-stream-retired, storage-service-list-retired and actor-user-roles-to-positions exist, 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 — an XInput alias 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/ and authorable-surface/ are BYTE-IDENTICAL across this change, because those generators enumerate runtime z.ZodType exports 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 says ConnectorInput does not exist, not that Connector now 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 — the ctx.user roles alias 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 from z.infer to z.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 Input from @objectstack/spec except the nine listed above: rg "\b\w+Input\b" --type ts over 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 of XSchema.parse() annotated with the bare name no longer compiles at the first defaulted key it reads (TS18048/TS2532), the signal that the annotation should be XParsed. pnpm check:spec-parsed-alias reports every bare alias as z.input and refuses both a bare z.infer alias and a reintroduced XInput synonym.
  • 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-shaped list(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.list was a single-level readdir, so a nested key a/b/c was invisible under list('a') (only a/b came back), and a subdirectory that stat succeeded on was pushed into the result as a file, yielding a StorageFileInfo whose size is a directory inode and which cannot be downloaded at all. S3StorageAdapter.list was RECURSIVE (ListObjectsV2 matches the whole key) and read neither IsTruncated nor ContinuationToken, 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 off list(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 the SwappableStorageService pass-through (which itself rejects when the active adapter has no list), 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, as data-driver-find-stream-retired. ADR-0049 / ADR-0087.
    • Done when: No code calls storage.list(...) on the file-storage service or on any IStorageService value. 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 IMPLEMENTS list keeps 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 against IStorageService that forwards to inner.list is exactly such a caller — the one in @objectstack/service-storage goes with the adapters' own list implementations, 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. list exists 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-argument list(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 in storage-adapter-list.conformance.test.ts rather 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.
  • tool-requires-confirmation-retired — ai.tool.requiresConfirmation → put the operation behind an ACTION and set ai.requiresConfirmation: true there — 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 member confirm: true on the request and is REFUSED without it with ACTION_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's ai.exposed opt-in, today the action door reached from the MCP run_action tool, while REST /actions is not ai.exposed-gated and sits outside the gate; and confirm: true is an unverifiable caller claim, so the gate makes forgetting loud without proving a human approved
    • Why not automatic: ToolSchema.requiresConfirmation accepted true and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not ToolRegistry.execute, not POST /ai/tools/:name/execute, and not the MCP bridge, which derives destructiveHint from 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.requiresConfirmation carries 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. ToolSchema was 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/spec is guaranteed to hit. Registered by the stock reconciliation of the v17 train's breaking changesets: the retiredKey() tombstone shipped with the change that executed ADR-0033's deletion of this unenforced key, and still stands in ai/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 what spec-changes.json, the upgrade guide and os migrate meta project 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 carrying ai.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 with ACTION_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's ai.exposed opt-in — today the action door reached from the MCP run_action tool — while REST /actions is not ai.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; and confirm: true is 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.
  • 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/ui modules declared a full interaction-configuration vocabulary — 22 z.object sites 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.json listed 109 keys under these defs and content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following dnd.mdx and writing a dnd: block onto a page component was rejected by PageComponentSchema for 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 under packages/spec/src imported any of the five except the ui/index.ts barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus defineStack's ObjectStackSchema (25 roots, 4742 nodes) reached none of the 21 named object shapes, while PageSchema, WebhookSchema and StateMachineSchema all resolved direct and 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 to strictObject and 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: readOnly was 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 no sys_metadata row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as plugin-runtime-family-retired (the kernel plugin-runtime family) and the HttpServerConfig retirement (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 THEME animation block — 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/spec or @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 in ui/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 bare ConflictResolution from @objectstack/spec/ui as a TYPE for your own offline code, declare that union locally — it is your client's policy, not the platform's. @objectstack/spec/integration's ConnectorConflictResolution (connector sync) and @objectstack/spec/api's ConflictResolutionStrategy (route merge policy) are different concepts and are untouched.
  • 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 surviving NotificationType / NotificationSeverity / NotificationPosition vocabulary; public access to a form is granted by the LIVE FormView.sharing block (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/ui vocabulary 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 against origin/main before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in packages/spec/src declared a key of either type (ui/notification.zod's only non-test importer was the barrel; ui/sharing.zod's were the barrel and ui/view.zod.ts, which names its SIBLING SharingConfigSchema), measured by resolving specifiers rather than substring-matching, because the repo holds two sharing.zod modules and a substring test miscredits stack.zod.ts to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus defineStack's ObjectStackSchema, over build-schemas.ts's own walk including its derived-clone bridge, never reached either, while Page / Action / DashboardWidget / Webhook and SharingConfig itself all resolved root-graph in 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 takes EmbedConfigSchema in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no sys_metadata row 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, whose readOnly was 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: NotificationAction lost its wrappers when the dual-source cleanup removed the ./ui copies of NotificationSchema / 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 on ui/notification.zod's tombstone; the removal itself stands — and EmbedConfig lost its key at 17.0.0 when the 2026-06 liveness audit retired App.embed (no iframe route ever read it) — that key still stands as a retiredKey() tombstone in app.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.zod KEEPS SharingConfigSchema, a live door carried by FormViewSchema.sharing and read by rest-server.ts to mount the anonymous form routes, and ui/notification.zod keeps its three presentation enums. objectui consumed NotificationActionSchema.shape.variant as a VOCABULARY (never a parse) to pin its own hand-written NotificationActionButton interface — 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, EmbedConfigSchema or EmbedConfig from @objectstack/spec or @objectstack/spec/ui — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in notification-embed-retirement.test.ts). The same pin asserts the SURVIVORS in the same run, and that half is equally load-bearing: NotificationTypeSchema / NotificationSeveritySchema / NotificationPositionSchema and SharingConfigSchema must 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.sharing still gates the anonymous endpoints on allowAnonymous + publicLink.
  • 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.widget is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (RuntimeWidgetManifest / RuntimeWidgetSource in @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 on label / description — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE system/translation.zod.ts surface. 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.ts published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and ui/i18n.zod.ts published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on origin/main immediately before the removal with their controls passing in the SAME run: (1) no module under packages/spec/src imported widget.zod at all, and the only imports of i18n.zod anywhere name I18nLabelSchema / AriaPropsSchema (both KEPT), so no schema declared a carrier key — field.widget is a z.string() naming a registered component and has never referenced WidgetManifest; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus defineStack's ObjectStackSchema reached none of them, while PageSchema / ObjectListViewSchema resolved direct in 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 / DateFormat DID have a carrier key (LocaleConfig.numberFormat / .dateFormat) but the carrier was itself doorless, so the subtree was no door rather than no gate and goes whole — leaving the two leaves behind would strand exported schemas with no consumer, which an author reads as a capability. I18nObjectSchema was 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 is system/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 to strictObject was 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, whose readOnly was precisely validated and read by nothing). With no carrier key there is nothing to tombstone and no sys_metadata row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as ui-interaction-config-family-retired, plugin-runtime-family-retired and the HttpServerConfig retirement. ⚠️ WidgetManifest.performance's own retiredKey() tombstone (left by the close-out sweep that removed the inert performance keys no renderer applied) is SUBSUMED here, the way the kernel activationEvents tombstone 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. FieldWidgetPropsSchema survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in authorable-surface/ or json-schema.manifest/ — its onChange is a z.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's error with no alias, the form renderer began producing it, and packages/fields/src/__tests__/spec-symbol-batch7.test.ts pins the shape against import type { FieldWidgetProps } from '@objectstack/spec/ui' as an intentional tripwire. Re-verified on objectui origin/main 2026-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) or LocaleConfig(Schema|Parsed) from @objectstack/spec or @objectstack/spec/ui — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in ui/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 a field.widget: "my_picker" string is untouched. FieldWidgetProps / FieldWidgetPropsSchema / FieldWidgetPropsParsed, I18nLabel(Schema) and AriaProps(Schema) all still resolve on @objectstack/spec/ui and are asserted to. ⚠️ objectui needs a companion PR in the same window: packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts asserts the spec STILL owns WidgetManifest / 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), and packages/types/src/widget.ts's "Renamed off the spec's WidgetManifest name" comments now point at names that no longer exist. Both are prescribed responses to this removal, not collateral damage.
  • 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 authored sys_user_permission_set seed row (stack data entries) or data-door write that still carries it. Delegation semantics live on sys_user_position, where delegated_from remains 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 keeps reason (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 (isDelegationWrite returns false for every other object, so assertSelfDelegation is 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 stack data seed 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_from on 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 in reason instead, 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.
  • 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. So in: [] is still accepted (a declared predicate both drivers implement), equals: ["a","b"] is still accepted (it lowers to a deep-equality comparand), and is_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 naming value, 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 lint now 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 reading operator: "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.
  • 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 with type: '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 a viewId, 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.ts declares no listViews / getView / createView / updateView / deleteView; its only view resolver is getUiView), no route (packages/rest/src/rest-server.ts never mentions viewId, so nothing viewId-addressed is reachable over HTTP at all), and no caller (the only ViewProtocol mention 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's getView(name: string) is another class, and objectui's getView(objectName, viewId) resolves through client.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 what GET /ui/view/:object/:type answers AND its 2026-08-07 maintainer ruling both read GetViewResponseSchema (zero implementations) as the contract of GET /ui/view/:object/:type, whose declared response is GetUiViewResponseSchema — 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) or DeleteViewResponse(Schema) from @objectstack/spec/api, and no host declares a ViewProtocol member. Reading and writing views still works end to end through the surfaces that were always the live ones: GET /api/v1/meta/view/:name returns the stored definition and GET /api/v1/ui/view/:object/:type returns the resolved view, both unchanged by this removal. GetUiViewRequestSchema / GetUiViewResponseSchema still resolve — they are the shapes that ruling meant.
  • workflow-service-slot-retired — CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow → the live mechanisms the slot only ever pointed at: state_machine validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + record_change flows (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).

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.

On this page