ObjectStackObjectStack

Protocol 17 → 18 upgrade guide

The mechanical conversions and the semantic to-dos for moving metadata from protocol 17 to 18, generated from the ADR-0087 registries.

Not released yet. Protocol 18 ships in no published @objectstack/spec (this tree versions it 17.7.0). This page is generated from main and changes as entries land; nothing on it is shipped until the release that carries protocol 18.

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 17 → 18

Protocol 18 extends the publish-time refusal of unresolved placeholders, which protocol 17 applied to datasource connection config, to the memory driver's config-material persistence keys: persistence.path (file persistence and the auto override) and persistence.key (localStorage and the auto override) refuse ${…} placeholder syntax at publish. Nothing resolves a placeholder there — the driver would create a literal ./${DATA_DIR}/… path or write under the literal localStorage key — the same authored-under-a-false-belief shape, one surface over. The memory driver's initialData stays deliberately unjudged: it carries arbitrary record values, where a literal ${…} may be legitimate data. It also retires MetadataPluginConfig.additionalTypes (ADR-0049 enforce-or-remove): the key was documented as THE plugin kind-declaration channel and read by nothing — the manager's type registry is seeded once from DEFAULT_METADATA_TYPE_REGISTRY and never merged with it, so authoring it configured nothing. A kind enters the live set as a side effect of registering an item of that kind. It also refuses malformed field scale/precision declarations: both are digit counts, so a non-integer or negative value (scale: 2.5, precision: -1) has no defined meaning — the write-time scale check, which refuses an over-scale value rather than rounding it, deliberately left it unenforced rather than invent floor/round semantics, which made the declaration silently inert. The schema now refuses both at parse (z.number().int().min(0)); the mechanical conversion deletes a malformed value from old sources and stored rows (behaviour-preserving), and the semantic entry tells the author to re-declare the count they meant. Finally, it removes the objects["*"].allowExport grant from the shipped admin permission sets — admin_full_access, organization_admin and the derived organization_admin_no_bypass. Measured on 17.0.0 GA, that wildcard made the export axis undeniable for an org admin: an application could declare an object exportable by nobody and the platform exported it anyway, with no supported opt-out, because a code-package set cannot be edited (403 [not_overridable]) and the admin held no app-authored set in which to write the per-object false that would have won. It is the earlier removal of member_default's CRUD wildcard applied to the export axis, which had kept its wildcard by omission rather than by decision. From 18 an admin exports exactly what an app-authored set grants — a posture the same run measured to be already precise. Unlike everything else in this step it changes no schema, so nothing refuses at publish: the upgrade signal is behavioural and belongs here. Finally, it converges record:chatter / record:discussion position on the renderer's vocabulary (maintainer ruling 2026-08-15): the schema declared sidebar/inline/drawer — values no renderer branch ever compared, so the schema's own sidebar default silently rendered in flow while the value that actually docks the panel (right) was refused at publish. The row now speaks bottom/right/left; the mechanical conversion rewrites the old spellings (sidebar → right, inline → bottom, drawer → right), and the three schema defaults (position, collapsible, defaultCollapsed) are dropped per the maxVisible principle — renderer fallbacks stay the renderer's facts. It also retires targetVariable on element:text_input and element:record_picker (ADR-0049 enforce-or-remove): a declarative hint with zero readers in any repo — the live binding runs the other direction, resolved from the page variable whose source names the component's id (PageVariableSchema) — so an author who wrote only targetVariable got an input that wrote nothing, with a success receipt. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); the tombstone's prescription says how to declare the binding that works. Finally, it retires the whole element:filter element (ADR-0049 enforce-or-remove at ELEMENT grain — the wider finding that the targetVariable retirement recorded and left for its own card): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. List surfaces own their filtering: a view's userFilters quick-filter bar / the list toolbar's filter builder. It also retires the whole element:form element (ADR-0049 enforce-or-remove at ELEMENT grain — the element:filter shape one element over, recorded by that retirement's own verdict sweep): no renderer for the element ever shipped in any repo — objectui registers none, Studio's designer palette lists it as a no-renderer exclusion naming the live replacement, and the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so every one of its six authorable keys was a capability claim nothing kept. All six are retiredKey tombstones; the mechanical conversion strips them from old sources (pure lossless deletes) and leaves the bare node, which the parse then refuses by name — delete the component. Use the object-bound object-form block instead — rendered, designer-publishable, its props declared for the component-props gate, and carrying the same intent (objectName, fields, mode, submitText). It also closes the two explicit column lists on relationship fields: field.inlineColumns entries are now the strict, name-keyed InlineGridColumnSchema (mirroring the objectui grid renderer's measured reads — objectui aligned the widget to name and retired the field spelling with no tolerant alias), and field.relatedListColumns entries are child field-name strings (the only form the related-list renderer hydrates fully). Both were z.array(z.any()) — a mis-keyed column published clean and rendered as blank cells with the right row count. The mechanical conversion respells inline { field } entries as { name } and folds related-list column objects to their identity string; unknown keys are named rejections at publish from this major. It also retires measures.<metric>.filters on analytics cubes (ADR-0049 enforce-or-remove): a declared per-metric raw-SQL filter with zero consumers — both SQL strategies aggregate the metric's sql and never read filters, so a hand-authored filters: [{ sql: "stage = 'closed_won'" }] parsed, registered, and silently returned the UNFILTERED aggregate under the author's metric name (the same defect the dataset path had, on a hand-authored cube; the dataset half was repaired through its own structured channel when the analytics strategy began compiling each dataset measure's filter). The raw-SQL fragment also ran against the platform's structured-FilterCondition direction — it cannot be parameterized, re-targeted per driver dialect, or walked by the lint filter rules. The mechanical conversion strips the key from old sources (pure lossless delete — it never had an effect to lose); filter at query time with where, or use an ADR-0021 dataset measure's structured filter (a metric's own sql is a column reference, see cube-member-sql-expression-retired). Finally, it retires the stack themes carrier and ThemeSchema whole (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, disposition B: 退役授权面): the pipeline was live from the authoring gate through artifact ingest and stopped there — zero non-test readers of stored theme items, theme never a registered metadata type, no first-party app mounting the spec-aware provider, nothing selecting an active theme — so an authored theme shipped through every green gate and changed nothing on screen. app.branding stays the one colour surface; objectui's ThemeEngine/ThemeContext and their unit tests are retained. Semantic rather than mechanical: an authored palette has no lossless target (N themes vs M apps is a judgment), so the entry prescribes the hand move instead of deleting authored content silently. It also retires the record:highlights highlight-field icon (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21, executing the 2026-08-20 census verdict): a declared key with zero read points in any direction — objectui's renderer normalized the authored object and carried icon into a highlight chip with no icon slot, useRegisterHighlightFields registers field NAMES only (structurally unable to carry it), and the Studio designer publishes the field list as plain strings — while six author-facing surfaces advertised the key (the shape that got the reference-rail icon refused, on the highlight chip). The mechanical conversion strips the key from the object entries of every record:highlights fields[] (pure lossless delete — the chip renders label and value only, so it never had an effect to lose); there is no replacement, and the live neighbour readonly, declared because the chip's read-only gate reads it, is untouched. It also retires the import mapping lookup transform's steering params (ADR-0049 enforce-or-remove — the sub-walk half of the 17.0.0 mapping cleanup that retired extractQuery / errorPolicy / batchSize): fieldMapping[].params.object / .fromField / .toField / .autoCreate declared a per-entry reference-resolution dialect the import path never implemented — lookup copies the cell through and resolution runs off the target field's own metadata — and autoCreate read as create-if-missing while an unresolved reference actually fails the row (import_reference_not_found), with or without the key. The eleven alias spellings convert to guidance so every spelling lands on the prescription; the mechanical conversion strips the four keys from stored sources (pure lossless deletes — none ever had an effect to lose). Finally, it retires the component-translation copy key pages.<name>.components.<id>.submitLabel and its submit alias (ADR-0049; maintainer ruling 2026-08-22): the face is measured, not mirrored — each copy key exists because some component in ComponentPropsMap declares it — and submitLabel's only declarer was element:form, retired whole above, so the key had no declared component left to translate and the resolver overlay was its only reader. Retire won over re-anchor because the live form surface (object-form) speaks submitText (I18nLabelSchema), localizable at its own authoring site; re-anchoring would have widened the face for one word. The mechanical conversion strips the key from stored bundles and items (pure lossless delete — nothing read it once element:form was retired), at the acknowledged cost of dropping the bespoke-component route for that one word. Finally, it retires page.components[].responsive and the whole ResponsiveConfig layout vocabulary it carried (ADR-0049 D2; maintainer ruling 2026-08-22): the key was the destination the dashboard.widgets[].responsive tombstone prescribed as the live alternative, and a two-repo measurement (tsc-probe methodology with positive and negative controls) found the claim false — objectui's two implementations of the contract (useResponsiveConfig, ResponsiveProtocol) had zero callers and nothing read .responsive off a page component, so the prescribed migration moved an inert key to an inert key while the platform's own error message vouched for it. The same change repairs every shipped text that carried that redirect. ResponsiveConfigSchema, its two breakpoint maps and the BreakpointName enum had no other authorable carrier and leave with the key (RETIRED_DEFS_BY_MAJOR[18]); the live per-breakpoint channel on a page component is responsiveStyles (ADR-0065), which objectui really compiles. The mechanical conversion strips the key from stored pages (pure lossless delete — it never had an effect to lose). Finally, it retires nine of the eleven members of the plugin manifest's contributes block (ADR-0049 enforce-or-remove; triage graded 2026-08-21, cloud census leg discharged clean 2026-08-24): events, menus, themes, translations, actions, drivers, fieldTypes, functions and commands. A census of all three repos, with controls, measured that the whole monorepo contains exactly one non-test read of manifest.contributes, and it reads kinds; the other nine members parsed, entered the manifest, and changed nothing, while published docs and the schema's own JSDoc kept teaching them (commands documented Commander.js resolution the CLI dropped for oclif; fieldTypes advertised a registration seam that never existed). All nine are retiredKey tombstones mirroring loading; kinds survives (live reader), and routes was left to a ruling of its own, which retired it as well (the plugin-manifest-contributes-routes-retired entry). D3 semantic, no D2 conversion: a manifest is not a stack collection member, so a conversion would be a transform with no seam that ever runs. On the surviving kinds bucket it also retires the globs sub-field (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24): the schema promised that declaring globs enables file-type discovery, but discovery globs filePatterns off the metadata type registry — which contributes.kinds does not extend, as metadata-plugin.zod.ts records outright — so an authored globs was accepted, stored, served back through GET /metadata/kind, and never consulted (zero value reads; the only non-test occurrences were the schema declaration and two type positions). The kind bucket itself and its id are untouched; file-type discovery stays single-channel on filePatterns. D3 semantic plugin-manifest-kind-globs-retired, same no-seam reasoning. Finally, it retires object-grid's defaultSort (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, decision-inbox batch 4 — the producer half of objectui's table.defaultSort retirement, which the maintainer's 2026-08-22 「接受所有」 ruling on objectui's sort sink ordered): the legacy second spelling of sort, a single { field, order } pair the renderer read only when sort was absent (measured at the .objectui-sha pin 190fbd01d, plugin-grid/src/ObjectGrid.tsx:1244-1246 and :2847, which wraps it [schema.defaultSort] — the exact array shape sort carries). One intent, two spellings; objectui's mirror schema is parity-test-only and parses nothing at runtime, so only the spec strictObject can refuse the key. The mechanical conversion carries the pair over — renamed to sort and wrapped in the array shape — when sort is absent, and strips it as a pure lossless delete when sort is present (the renderer's own precedence made it unread then). Finally, it retires the object-permission lifecycle bits allowRestore and allowPurge (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-26, decision-inbox batch 5, which chose retiring the two bits over gating operations that do not exist): the restore / purge ObjectQL operations the bits claimed to gate have never existed — no destructive lifecycle verb is in the engine's dispatch vocabulary, which a test pins — so granting the bits delivered nothing, and an author who declared allowPurge: false believed a lock on GDPR hard-deletion existed when the operation itself did not. Both keys are retiredKey tombstones; the evaluator's pre-mapping rows retired in the same batch (a dispatched restore/purge stays denied fail-closed via the DESTRUCTIVE_OPERATIONS backstop, so there is no ungated window), and the mechanical conversion strips the keys from every object grant in permissions[].objects (pure lossless delete — they never had an effect to lose). allowTransfer is ENFORCED — the server guards who may rewrite a record's owner — and stays. The keys return with the M2 lifecycle initiative (feature + RBAC in one batch), which stays open as their anchor. Finally, it narrows the per-option default key OUT of the form-view options vocabulary (ADR-0049 declared-but-unenforced; maintainer ruling 2026-08-28 on the console form renderer's analysis, disposition 甲): SelectOptionSchema serves two surfaces and only the OBJECT-field face reads default (enforced there by a maintainer ruling of 2026-08-10 — applyFieldDefaults falls back to the option marked default: true; that face, its alias rows and its precedence pin are untouched). On a form-view field's option list the key parsed clean and nothing read it — the insert-path fallback consults the object definition's options, never a form view's, and no form renderer seeds a value from it (measured against the console's form controls, none of which reads the key; the ruled census found ZERO authored occurrences across the tree, the example apps and the published *.form.ts corpus). The FormView vocabulary's own option shape (FormSelectOptionSchema, ui/view.zod.ts) now refuses the key with the prescription; the mechanical conversion strips it from stored sources (pure lossless delete — it never had an effect on this surface to lose). It also retires the paper metadata-customization protocol whole (ADR-0049 enforce-or-remove, maintainer ruling 2026-08-29): kernel/metadata-customization.zod.ts — the three-layer platform/user patch-overlay model with field-level change tracking and a 3-way-merge story — was exported, documented as the customization architecture, and implemented ONLY by an unreachable packages/metadata limb (no route served the paper …/overlay/…/effective endpoints; the four optional service members were called only by their own unit tests). ADR-0126 §6 wall 4 supersedes it on the record ("nothing may build against it"). The module's seven defs and the three section-5 API contracts leave via RETIRED_DEFS_BY_MAJOR; the authorable carriers MetadataPluginConfig.customizationPolicies / .mergeStrategy and MetadataManagerConfig.persistence.overlayWritable are retiredKey tombstones (no D2 conversion — plugin/manager configs are not stack collection members, the additionalTypes reasoning). The customization that actually ships: ADR-0005's org overlay and ADR-0126's packaged-metadata model. Finally, it canonicalizes the legacy objectql field-key dialect reference_to → reference on lookup/master_detail fields (the server half of the maintainer's 2026-08-31 ruling that the server normalizes the protocol and the renderer only executes it). FieldSchema has always refused reference_to by name, but stored sys_metadata rows written by seams that bypass the parse still carry it, held up today only by objectui's reference ?? reference_to fallback arms — which the ruling's objectui half deletes. The mechanical conversion renames the key (the house precedence for a shadowed alias: a canonical reference wins, a disagreeing pair is kept for the author), replays on every stored-row rehydration so the serve face only ever emits the canonical spelling, and os migrate meta rewrites old sources; the authoring-surface rejection with its rename prescription is unchanged. It also retires connector.errorMapping (ADR-0049 enforce-or-remove; triage ruling 2026-09-02): ErrorMappingConfig (4 keys) and its ErrorMappingRule[] (7 keys) were authorable through ConnectorSchema — and, via DeclarativeConnectorEntrySchema, through stack.connectors[] and the /meta/connector door — and read by nothing: no provider, dispatcher or materializer ever mapped an external error through the rules, so unmappedBehavior configured nothing and a rule's userMessage was never shown to anyone. That spelling is the live API-error channel's (ApiError.userMessage), so an author who wrote a rule here reasonably believed they were marking a refusal for an end user; the failure was silent in both directions. The carrier key is a retiredKey tombstone on the non-strict ConnectorSchema (a bare deletion would be a silent strip), the three defs — integration/ErrorMappingConfig, integration/ErrorMappingRule and the orphaned integration/ConnectorErrorCategory enum — leave via RETIRED_DEFS_BY_MAJOR, and the mechanical conversion strips the block from connectors[] (pure lossless delete; it never had an effect to lose). It also retires the fourteen hour/minute/day-shaped deadline keys of the incident-response, training and change-management families (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02): six on the incident-response schemas, five on the training schemas and three nested in the change-management schemas, every one on the published surface and read by nothing — the schemas are mounted by no stack key and registered as no metadata type — so a compliance author who wrote triageDeadlineHours: 4 held a deadline the platform never kept. All fourteen are retiredKey tombstones (the schemas are not strict; a bare deletion would be a silent strip) with no D2 conversion, for the additionalTypes reason: none of these schemas is a stack collection member, so the chain has no seam. It then retires those three compliance-shaped families WHOLE (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05, ruled A, not roadmapped): the nineteen defs of system/incident-response.zod.ts, system/training.zod.ts and system/change-management.zod.ts — roughly a hundred declared keys, exported from @objectstack/spec/system, mounted by no stack key, registered as no metadata type, absent from the liveness ledgers, read by nothing repo-wide (examples, skills and objectui at the pinned sha included) — leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry per family; the fourteen deadline-key tombstones leave with their defs' source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. Boolean capability claims such as notifyRegulators, requirePostIncidentReview, trackCompletion and approval.required were the sharpest declared-≠-enforced shape left: an author writing notifyRegulators: true held a compliance promise the platform never kept. And it resolves the branch the deadline-key ruling held open — no roadmapped e-signature consumer — so ESignatureConfig.expirationDays / reminderDays (data/document.zod.ts, defaults 30 / 7 days, read by nothing) are retiredKey tombstones with no D2 conversion (document is no stack collection member), registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry. Finally, it moves the unit of every duration-shaped z.number() key whose unit lived only in its description into the key name (maintainer ruling 2026-09-02, no grandfathered baseline): hook.timeout and job.timeout become timeoutMs (mechanical rename, retired from the load path), and the five keys with no stack seam — MetadataManagerConfig.cache.ttl / cache.databaseLoader.ttl (seconds and milliseconds fourteen lines apart under one name), DriverOptions.timeout, and the tenant connectionPool.idleTimeout / accessControl.sessionTimeout whose unit the reference pages never published — are retiredKey tombstones with a semantic entry each, naming the suffixed key. The data, ui, ai and integration remainder closes the same sweep: dashboard.refreshInterval → refreshIntervalSeconds, the connector pair health.circuitBreaker.monitoringWindow → monitoringWindowMs and triggers[].interval → intervalSeconds (both halves later absorbed by the removal of the block each key lived in — see the connector retirements below), and the two datasource config keys memory config.persistence.autoSaveInterval → autoSaveIntervalMs (BOTH union arms — the auto arm forwards the same value to the same file adapter, so splitting them would have left one value with two spellings) and turso config.timeout → timeoutMs all convert, because a dashboard, a connector and a datasource are stack collection members stored as rows; the two with no seam — ConversationAnalytics.duration, computed at runtime and never authored, and NoSQLQueryOptions.timeout, a per-call driver argument — are retiredKey tombstones with a semantic entry each. That remainder is what takes check:duration-unit-keys to zero offenders over packages/spec/src/**; the gate goes red again by design when its declared population widens beyond that subtree. It also retires the three outer keys of MetadataManagerConfig.cache — enabled, ttlSeconds (the duration rename's respelling of ttl, never shipped) and maxSize — that the rename above surfaced (ADR-0049 enforce-or-remove): declared, defaulted and published, read by nothing — MetadataManager hands only cache.databaseLoader to the loader — so cache: { enabled: false } switched nothing off. All three are retiredKey tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 conversion (a manager config is no stack collection member); the rename is folded into the removal, so cache.ttl now prescribes deletion rather than a hop to a retired key. It also retires the seven cron-typed positions nothing evaluated (ADR-0049; the 2026-09-06 ruling retired each family rather than marking it experimental): the two export-schedule crons, ScheduleState.cronExpression, DataSyncConfig.schedule, CacheWarmup.schedule and the two disaster-recovery crons were parsed into the cron envelope and read by nothing (the D7 ledger row cron-declared-unwired). All seven are DELETED OUTRIGHT — no retiredKey tombstone, no RETIRED_KEYS_BY_MAJOR[18] entry, no D2 conversion and no D3 semantic entry — so this step replays nothing for them and migrate meta lists no edit: the keys simply stop existing. That the chain is silent does NOT make the deletion silent to an author: the PARSE strips (no schema here is .strict()), but above it lintUnknownAuthoringKeys names the dropped key for the one position a stack manifest reaches — os validate and os build both print connectors.<name>.syncConfig.schedule: 'schedule' is not a declared connector key, so its value is dropped at load., and os validate --strict EXITS 1 on that warning. The other six positions are unreachable from a manifest, so for those the parse-level strip is the whole of it. That is the maintainer ruling of 2026-09-10 on the retirement PR, taken over the seat recommendation to keep the connector D2, on the reading that customers do not upgrade major by major in order. It also retires the type: 'page' LIST-VIEW mount and its pageName binding (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a view could render nothing of its own and delegate to an already-published page, but only the spec half landed: no renderer ever routed it — objectui's list-view switch shares its default arm with grid — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding policed a mount that never mounted anything. The enum VALUE carries its prescription on the type enum's own error map (an enum-value narrowing has no tombstone to hang one on, the exportOptions 'pdf' precedent); pageName is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS both keys rather than rewriting type to 'grid': type defaults to grid in the schema, so deleting it lands the row on exactly what it already rendered without this registry guessing a view type. The surviving page mount is the app navigation item (PageNavItem.pageName), untouched. It also retires object-kanban's quickAdd (ADR-0049 enforce-or-remove; the spec half of the director-seat ruling of 2026-09-08 that the board grows no inline record-creation path and retires the key). The board FORWARDED the key into the shared renderer but the affordance is gated on both quickAdd and onQuickAdd, and onQuickAdd is a host-supplied FUNCTION JSON cannot carry and no producer puts on an object-kanban node — so the gate was permanently false. The drop was NOT silent, and that is what made it worse than silence: objectui's html tier reported the published key as unknown-prop, the same diagnostic a typo gets, so an author following the contract met a tool contradicting it with no way to tell which side was wrong. A retiredKey tombstone on ObjectKanbanPropsSchema with one D2 conversion that is a pure lossless DELETE (the key never had an effect to preserve) scoped by component type. Delete the key; object-kanban offers no quick-add control. It also retires the bare STRING sort clause on the list-view doors (ruled 2026-09-07: the legacy string clause is retired, one spelling, the array). This is the PRODUCER half of the seam whose consumer half shipped in objectui first: convertSortToQueryParams now refuses a runtime string, so ListViewSchema.sort was minting documents its own consumer rejects — a document that validated upstream failed downstream, and the author was told off by the wrong layer. Like the type value above it is a VALUE narrowing with no tombstone to hang a prescription on, so the surviving array member's own error map carries it, keyed on issue.input being a string. The D2 conversion REWRITES rather than strips, because the clause is losslessly mechanical: 'created_at desc' is the tuple { field, order }, a bare field name meant ascending and is written out as order: 'asc', and the comma-separated multi-key form becomes one entry per key in the same order. A string that does not parse as that grammar — the '-field' dialect above all — is left alone and meets the door instead: that dialect belongs to RecordRelatedListProps.sort, never reaches convertSortToQueryParams, and retiring it was NOT ruled. It also removes page.assignedProfiles (ADR-0090 D2 / ADR-0049 enforce-or-remove; maintainer ruling 2026-09-12 「同意」). The key was authorable on the published PageSchema and named for the Profile concept ADR-0090 D2 deleted, while the schema's own alias table CORRECTED an authored profiles: into it — two files from security/permission.zod.ts answering the same word with "no Profile concept". Measured across this repository and objectui it had zero readers, so a page that "assigned profiles" was open to every caller who could reach it. It is a retiredKey tombstone on PageSchema — the def is still parsed from the page root, so there is an author to teach — and the two alias entries became refusals naming the permission-set route. The D2 conversion STRIPS the key — there is no lossless target, because which permission set a given profile name corresponds to is a judgement no walker can make, which is what the paired D3 semantic entry is for. Finally, it removes aria from the chart config (ADR-0049 enforce-or-remove; maintainer decision of 2026-09-12 — judge the protocol wrong for this one key). It is the last member of the aria family retired for the same measured reason as dashboard.aria and dashboard.widgets[].aria before it: an ARIA block an author can declare and nothing lowers to the DOM. It survived those two sweeps by depth — it sits inside the widget’s chartConfig bag, which no drill had reached until the per-key pass recorded in liveness/dashboard.json. That pass found aria to be the one ChartConfigSchema key with no reader on EITHER face: the chart implementation declares no aria prop, the presentation lowering names it nowhere, and the react block omits it from <ObjectChart>’s dataProps. Remove rather than enforce, because the same chart config already carries a WORKING accessible-name channel in description (lowered as role="img" + aria-label), and giving aria a reader would put two accessible-name sources on one element behind a precedence rule nobody has written — one node, one accessibility vocabulary. The tombstone rides ChartConfigSchema and therefore copies into ReportChartSchema, so the key is registered twice; the D2 conversion STRIPS it from all three authored sites (dashboards[].widgets[].chartConfig, reports[].chart, reports[].blocks[].chart) as a pure lossless delete — it never had an effect to lose. The two alias spellings that pointed at it, accessibility and ariaProps, became refusals carrying the same prescription rather than renames onto a tombstone. It also states, and enforces, who owns a dataset-bound chart's STRUCTURE (ADR-0021; maintainer ruling 2026-09-12): the dataset decides which series exist and which column each one reads, chartConfig carries appearance, and dashboard.widgets[].chartConfig's type, xAxis, yAxis and series are refused by name on that carrier — the widget's own type is the chart family and dimensions/values are the selection. An authored yAxis[].field was a live membership channel: the renderer synthesised a series from it when the chart declared none, so one authored axis could silently re-point a dataset-bound series at another column and the chart still drew. The D2 conversion strips the four keys from dashboard widgets only — ReportChartSchema and the inline-data react <ObjectChart> tier keep their own axes — and the paired semantic entry carries what the stripped keys were saying, because an authored axis field may name a column the widget never selected and no walker can move that intent into the dataset. Finally, it splits the translation bundle type in two (maintainer ruling 2026-09-13: settings copy belongs to the platform): the platform bundle keeps all eleven groups and the per-app bundle (stack.translations, defineTranslationBundle) no longer declares settings, which is keyed by SettingsManifest.namespace and only platform code declares a manifest. Both bundles load into ONE served tree, so an app-authored settings branch did not sit inert — but nor did it override the platform: the app’s bundles arrive in AppPlugin’s start() (Phase 2) and the platform’s at kernel:ready (Phase 3), and deepMerge gives the later source the leaf, so what an application had was a GAP FILLER on a namespace it does not own — rendering only where the platform bundle carried no string for that key and locale. The registered translation ITEM follows the file door (maintainer ruling 2026-09-22: one app metadata type, two authoring doors, one accepted shape) and no longer declares settings either; there the group had been STRONGER, because the runtime-authored layer is read over the shipped bundles, so a stored item overrode the platform’s own copy. The D2 conversion strips the group from per-app bundle entries and from bare items alike — the runtime translation sync replays it over every stored row before merging — and the paired semantic entry says what the strip means at each door, because a notice reading "(removed)" says neither that an item’s overrides give way to the platform’s string nor that a gap falls back to the manifest's own English literal. Finally it retires object tenancy.organizationField (ADR-0049 enforce-or-remove). The key named the column a PLATFORM ROW is stamped from, as opposed to the column the object is WALLED by (tenantField); on an ordinary object those are the same column, and the entire protocol declared it exactly once — on sys_api_key, a better-auth-managed credential table this platform ships and no application authors. Its three readers were all platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still forcing every future piece of organization logic to ask "what if somebody set this?". The divergence is NOT retired, only its authorability: it moves to PLATFORM_STAMP_ORGANIZATION_COLUMNS in @objectstack/metadata-core, keyed by object name and read by the stamp face alone, so audit stamping, the approval-row writer and the automation-run recorder keep their behaviour with no authorable input. The conversion is a lossless delete, and a lossless delete still leaves the author a judgment, which the family's D3 entry object-tenancy-organization-field-retired carries — an application whose tenant column genuinely is not organization_id declares tenancy.tenantField, which both walls the object and stamps its platform rows. It also retires connector.connectionTimeoutMs (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was owed after the ruling that made its nine ledger siblings live deliberately left this one dead). Bounded, defaulted, .describe()d and served back by /meta/connector, so an author had every signal it worked — and no site ever applied it as a deadline. This retirement is NOT the zero-mention shape: five sites outside packages/spec read the key (the materialization fingerprint and the provider-context build in the automation service, ctx.connectionTimeoutMs in the rest and openapi provider factories, and the ?? 30000 fallbacks that put it back on the reported def), but every one is a pass-through whose only termini are the def GET /connectors echoes and the fingerprint that decides whether to re-materialize. The one mapping from authored policy onto the platform's outbound fetch was handed retryConfig and requestTimeoutMs only, so the key was carried and never honoured — the same parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was the 实现 arm available: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase, so bounding time-to-response with it would kill a slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. requestTimeoutMs is the replacement and the bound the platform can keep. The carrier key is a retiredKey tombstone on the non-strict ConnectorSchema (a bare deletion would be a silent strip), registered under both def keys because DeclarativeConnectorEntrySchema carries it too, both carriers wrapping the same private ConnectorBaseSchema; the D2 conversion strips it from connectors[] as a pure lossless delete — it never had an effect to lose — because a stored connector row CAN carry it (the PUT /meta/connector/:name door persists the authored value and the stored-row rehydration seam is live for this type, both measured); and the withdrawn ConnectorProviderContext member, which is code and has no authored source to rewrite, leaves via the paired semantic entry instead. Finally it gives the one-filter-orthography convergence (ruled 2026-08-25: one filter spelling platform-wide, the rule array) its mechanical half at rest (ruled 2026-09-12): the D2 conversion page-component-filter-record-to-rule-array rewrites a record-form or single-level AST filter at the converged rule-array doors — dataSource.filter, the object-* / element:number / element:record_picker filter props and object-grid.defaultFilters — to the rule array wherever the mapping is lossless, and leaves a filter carrying $and / $or / $not (or any part with no lossless rule spelling) exactly as stored, because flattening a combinator changes which rows a page selects. It is retired from the load path, so authors are still refused at the door and taught the array; the stored-row seams and this chain replay it. It also retires the view item's owner and hidden (ADR-0049 enforce-or-remove). Both sat on the view-item identity layer, were accepted by the strict authoring door and by the wire member the view write door validates, and were stored verbatim — and nothing read either: both switcher read paths filter on viewKind + object and sort on order, so hidden: true hid nothing, and no per-user scope ever read owner, so a view marked as one user's was listed for everyone who can read the object. Per-user view scoping is a parked direction (ADR-0017, amended 2026-09-04), not a shipped mechanism. Both keys are retiredKey() tombstones on the SHARED shape, because that shape also feeds the .strip() wire member, where a bare deletion would be a silent strip. The D2 conversion view-item-owner-hidden-removed strips them from the view item RECORD spelling only, as a lossless delete, in both collections a record travels in — views (stack sources and stored rows) and the assembled-manifest viewItems channel (package export, environment artifacts), whose registration parse would otherwise refuse an artifact assembled before this release. It also retires a joined report's chart at both coordinates (ADR-0049 enforce-or-remove): the joined renderer draws each block as a table and returns before the one container chart read, and no renderer reads a block's chart at all, so a chart on a joined report parsed, passed the chart-bindings lint, and plotted nothing. The key leaves JoinedReportBlockSchema's closed shape (its guidance table carries the prescription) and the joined arm of ReportSchema's refinement refuses a container chart; chart stays live on every non-joined report. The D2 conversion report-joined-chart-removed strips both as a pure lossless delete — neither ever had an effect to lose — because a stored report row CAN carry them (the Studio report form offered a block chart input until this change); it is retired from the load path, so authors are refused at parse rather than rewritten. It retires the view item's owner / hidden pair on the flattened overlay door too (ADR-0049; the view item's disposition for the same key pair, followed here as triage directed): the lean personalization PUT with no config declared its own owner / hidden, accepted and stored them, and nothing read either. Both are retiredKey() tombstones on the two overlay members with the view item's own prescription texts, and the D2 conversion view-overlay-owner-hidden-removed strips them from the flattened spelling (no config, no container slot) in views and viewItems, so a stored overlay row is served without them. A row that held other view keys is then valid again and re-saves; a row that held nothing but its identity and the two keys is left identity-only, which the door refuses, so it is badged invalid, refused on a whole-row re-save and reported failed by os migrate meta --stored --apply until it is deleted or given the setting its author meant. Its D3 record is the semantic entry view-overlay-owner-hidden-retired. It also narrows form layout to vertical | horizontal on both surfaces that declared the four-arm enum — the object-form page component and the form view (ADR-0049 enforce-or-remove). No renderer ever gave inline or grid a behaviour of its own: every form presentation folded both to vertical, multi-column is columns (honoured under either layout), and inline is a toolbar / filter-row pattern rather than a record-form layout — redundant vocabulary under the maintainer's family criterion (a capability mainstream platforms have is served once, here by columns), retired with no alias window. Both enums refuse the two values with a per-value prescription naming columns; the D2 conversion form-layout-inline-grid-to-vertical rewrites them to vertical (behaviour-preserving, columns untouched) on object-form page components, on every form payload a view carries, and on the assembled-manifest viewItems channel. It also removes currencyConfig.precision (ADR-0049 enforce-or-remove): declared and validated against ISO 4217, read by no renderer or runtime — a currency amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. The D2 conversion currency-config-precision-removed strips it from every field's currencyConfig as a pure lossless delete, which matters most at rest: the schema used to bake precision: 2 into parse output, so stored object rows and built artifacts carry it without anyone having written it. Retired from the load path; an authored key is refused with the prescription. It also retires the RLS policy's tags (ADR-0049 enforce-or-remove; graded RETIRE by the maintainer's criterion — no mainstream platform tags a row-level policy): the key promised categorization and reporting for governance and compliance, and nothing ever read it — the RLS compiler never consulted it and no preview rendered it. It is a retiredKey() tombstone on RowLevelSecurityPolicySchema (the priority posture one key over), and the D2 conversion permission-rls-tags-removed strips it from every policy in permissions[].rowLevelSecurity as a lossless delete, so a stored permission row that still carries it replays clean. It is retired from the load path, so authors are refused at parse rather than rewritten. Its D3 record is the semantic entry permission-rls-tags-retired. Finally, it removes aria from the action (ADR-0049 enforce-or-remove), the fourth member of the aria family after dashboard.aria, dashboard.widgets[].aria and the chart config's, and retired for the same measured reason: an ARIA block an author can declare and nothing lowers to the DOM. The liveness ledger had graded it live on an uncited "partial" note with no reader behind it; at the pinned renderer, none of the surfaces that render an action — button, icon, menu, group and bar, the row and bulk action menus, the record quick-actions toolbar — reads it. Remove rather than enforce, because every one of them already takes the accessible name from the action's required label (visible text, or aria-label on an icon-only action), and the node that places the actions carries the node-level aria block — a per-action block would be a second spelling of both. The D2 conversion action-aria-removed STRIPS the key from stack actions and object-nested actions as a pure lossless delete, retired from the load path so authors are refused at parse; its D3 record is the semantic entry action-aria-retired. It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): connector.health — the healthCheck probe (eight keys) and the circuitBreaker (six) — connector.status and the connector-nested webhooks, sixteen authorable keys with no reader outside the spec package. No loop ever polled a connector endpoint or tripped a breaker; nothing read an authored status (the runtime publishes a computed state, and participation is enabled); and a webhook nested in a connector was never registered as a webhook item, so it was never materialized or delivered — the top-level webhooks: collection is the delivered one. The three carrier keys are retiredKey tombstones on ConnectorBaseSchema, registered under both carrier defs; status, defaulted 'inactive', joins connectionTimeoutMs in the retired-default residue stage, because every 17.x parse emitted it into every connector. Seven defs leave whole — ConnectorHealth, HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm — and the D2 conversion connector-resilience-keys-removed strips the three keys from connectors[] and stored rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them would start deliveries that never happened). It ABSORBS the breaker half of the duration rename above: health.circuitBreaker.monitoringWindow → monitoringWindowMs is no longer converted, because the whole block it lived in is now removed. Finally it makes edge-branched decision nodes EXCLUSIVE (maintainer ruling 2026-09-23, 「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the branch, and taking every true branch is the declared mode: 'inclusive'. The D2 conversion flow-decision-mode-inclusive-explicit writes that key onto every decision with two or more conditioned out-edges and no conditions list, so a flow written while every true branch ran keeps its behaviour; it is a default flip, so it is retired from the load path AND refused by the flow rehydration seam and the artifact-ingestion door, and replays only here — the paired semantic entry carries the judgment the diff then asks for. BREAKING for flows stored in sys_metadata, by maintainer ruling: such a decision with no mode takes the first-match meaning on upgrade and nothing rewrites it; os migrate meta --stored lists each one for review, and mode: 'inclusive' is the one-line fix where a node meant every branch. It also retires the list view's own tabs (ADR-0049 enforce-or-remove). The key parsed and was stored at every list-view door and drew nothing: a list view's own tabs has no reader, the one component that would draw it has no production mount, and the tab strip above an object's records is the saved-view switcher, which renders one tab per listViews entry and reads no tabs key (userFilters.tabs, a different key of the same element type, is read and rendered, and stays). The key is a retiredKey() tombstone on the list-view shape (its prescription says how to move each tab to a named listViews entry); ViewTabSchema itself stays, because the page-only userFilters.tabs preset bar reuses it and renders. The D2 conversion view-list-tabs-removed strips the key from every list payload in stack.views[] as a lossless delete, and is retired from the load path, so authors are refused at parse rather than rewritten. It retires the inner name on cube members — measures.<metric>.name and dimensions.<dimension>.name (ADR-0049 enforce-or-remove) — by the mainstream criterion: Cube.dev and LookML key a member by its declared name, with no second inner name that can disagree. Both member bags are records, and every consumer already resolved a member by its record KEY, publishing and querying it as <cube>.<key>; the REQUIRED inner copy was read by nothing, and one that disagreed with its key was silently ignored. The keys are retiredKey tombstones on MetricSchema and DimensionSchema, and because the key was required, every stored or built cube carries it: the D2 conversion cube-member-inner-name-removed strips it from every member of every cube, retired from the load path, and its notice prints a disagreeing value beside the key that stays. Its D3 record is the semantic entry cube-member-inner-name-retired, which asks the author of a disagreeing name which spelling they meant. It also retires the connector triggers array (ADR-0049 enforce-or-remove; ADR-0041 keeps connector-event triggers in its third tier, as their own trigger package): the ConnectorTrigger shape — key, label, description, type (polling / webhook) and intervalSeconds — was read by nothing. The automation engine registered a connector's actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever entered, no polling loop read an interval and no receiver was driven by a webhook trigger, so a declared trigger never started a flow. triggers is a retiredKey tombstone on ConnectorBaseSchema, registered under both carrier defs; the provider-bound refusal of the key, whose reason (the provider derives triggers) was untrue, is gone with it, since the tombstone refuses every value on every carrier. ConnectorTrigger leaves whole, and the D2 conversion connector-triggers-removed strips the array from connectors[] and stored rows as a pure lossless delete — never turning a trigger into a flow, which is the author's decision (an api flow for an external event, a schedule flow for a scheduled pull, each calling the connector's action). It ABSORBS the trigger half of the connector duration rename (its breaker half went with health above), so connector-health-and-trigger-durations-unit-in-key, with neither half left, is no longer in this step. It also retires a cube's refreshKey whole — the refresh cadence every and the data-change probe sql (ADR-0049 enforce-or-remove). Nothing read either key, and no analytics result is cached, so a declared cadence refreshed nothing and every query was computed when it was asked, as it still is. The key is a retiredKey tombstone on CubeSchema, and the D2 conversion cube-refresh-key-removed strips the whole block from every cube as a pure lossless delete, retired from the load path. Its D3 record is the semantic entry cube-refresh-key-retired. A refresh cadence is declared again when a result cache exists. It also narrows the time stored form to the zone-less wall clock the record validator already enforces (ADR-0053 D-C1), so a field default or an action param default or value with a Z or a UTC offset is refused when it is authored or submitted rather than on every insert that falls back to it. The D2 conversion time-default-utc-suffix-dropped drops a Z or a zero offset, which names the same wall clock, and leaves a non-zero offset as stored for its author to rewrite; its D3 record is the semantic entry time-default-zone-refused. It also retires the page header's breadcrumb switch (ADR-0049 enforce-or-remove): no renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and the navigation trail is drawn once, by the app shell's header. The key is a retiredKey tombstone on PageHeaderProps, beside the icon that row lost at 17, and the D2 conversion page-header-breadcrumb-removed strips it from every page:header, true and false alike, retired from the load path. Its D3 record is the semantic entry page-header-breadcrumb-retired. The nav:breadcrumb component type is not part of it: the Studio page palette still offers it. It also retires connector-attached sync from the connector (ADR-0049, the ENFORCE route by ruling): connector.syncConfig — strategy, direction, realtimeSync, timestampField, conflictResolution, batchSize, deleteMode, filters — and connector.fieldMappings — source, target, defaultValue, dataType, required, syncMode — fourteen keys no engine ever executed, whose latest_wins and soft_delete defaults read as configured policy and did nothing. The capability is mainstream, so the definition moves rather than lapses: every mainstream platform binds a sync to its TARGET, so a mapping gains connectorSource, the rest / openapi connector it pulls from, the read action and an optional timestamp watermark, and a job sets the cadence (no schedule key returns to the connector). That binding is declared in this step and executed in a later one. Both connector keys are retiredKey tombstones on ConnectorBaseSchema, registered under both carrier defs; DataSyncConfig, SyncStrategy, ConnectorConflictResolution and ConnectorFieldMapping leave whole; and the D2 conversion connector-sync-keys-removed strips both keys from connectors[] and stored rows as a pure lossless delete — never writing a mapping, which would start writes that never happened. It also narrows an analytics cube member's sql — measures.<metric>.sql and dimensions.<dimension>.sql — to a column reference: a field of the cube's object, a relationship path ending in one, or '*' (maintainer ruling D, ADR-0021 "zero raw SQL / zero raw expressions" carried from the dataset layer to the cube members it compiles to; ADR-0049 enforce-or-remove). A SQL expression there names no single field, so no platform check could judge which fields it reads, and the two analytics strategies never agreed on it: the raw-SQL path ran it verbatim, the ObjectQL path refused it. It is now refused at parse with a prescription naming the ADR-0021 dataset form — a measure with its own structured filter for a conditional count or sum, and derived: { op, of: [...] } over named measures for a ratio, sum, difference or product. No D2 conversion: an expression has no mechanical rewrite into a dataset, so the semantic entry cube-member-sql-expression-retired carries the move, including the scale change a ratio makes (a derived ratio is a 0–1 fraction). It also closes the form view's inline grid columns: subforms[].columns, on view.form and on formViews entries, was z.array(z.any()) while a relationship field's inlineColumns was already the strict InlineGridColumnSchema, so a mis-keyed column published clean and drew a blank grid column, and scale on a currency column, which the other carrier refuses under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), published green. The carrier now references that schema, so both carriers are judged by it, with its own prescriptions. The D2 conversion form-view-subform-columns-canonicalized respells a { field } column as { name }, the respelling field-column-lists-canonicalized makes on inlineColumns: it rewrites stored rows and assembled artifacts and lists the edit under os migrate meta, and it is retired from the load path, so an author writing field meets the refusal. A view saved with a failing column is refused with the column schema's prescription, and a stored row carrying one is diagnosed at rehydration; neither is stripped, because which column an unknown key or a mixed field/name entry meant is the author's call, and a conversion that dropped the key would accept at load what the parse now refuses. Its D3 record is the semantic entry form-view-subform-columns-closed. On both carriers, the reach of inline-grid-column-currency-scale-refused extends to a column that declares no type: such a column takes its type from the child field, which the column schema cannot see, when the console hydrates it, so defineStack's cross-reference check re-parses a column whose name is a currency field of the child object as the type it renders as, and the refusal of its scale is the column schema's own. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. It has no D2 conversion, for the declared-type entry's reason: deleting the key is the migration, and a conversion that dropped it would accept it at load, the grace window ruling B refused. Its D3 record is the semantic entry inline-grid-column-identity-only-currency-scale-refused. It also gives the executor target of an action one spelling on the page blocks that run one. ActionSchema has always refused endpoint with the rename to target, while the action:button and action:icon component rows declared endpoint as a key of their own, and the console's api handler reads target only — so an api button authored with endpoint was accepted by the props gate and called nothing. The rows now refuse it with the same rename, read from the one alias table both share. The D2 conversion action-block-endpoint-to-target renames the key on an api action, where the rename is lossless, retired from the load path so authors are refused at the door while stored rows and os migrate meta replay it; an endpoint on a block with no actionType or another one is left as stored and reported as a TODO. Its D3 record is the semantic entry action-block-endpoint-spelling-retired. Finally, it retires the form field's publicPicker block (ADR-0087 D2, immediate — the maintainer's ruling E, which reverses the earlier ruling that had declared it): an anonymous public form no longer offers record search. The block opted a lookup, master_detail or user field on a public form into a picker served by an unauthenticated route; that route is deleted, and the public-form resolve route now leaves those three field types off the anonymous rendering unconditionally. The schema refuses the key with the prescription; the mechanical conversion form-field-public-picker-removed strips it from old sources and stored rows (lossless in effect — its only reader was the deleted route), and the semantic entry asks the author how a visitor should now choose: a select field with static options, or a form behind sign-in. It also closes the third carrier of the inline grid column: an object-master-detail-form page block's details was z.array(z.unknown()), so a key its renderer does not read and scale on a currency column, which the other two carriers refuse under the maintainer's rulings of 2026-09-23 (option B) and 2026-09-24 (option 乙), went through objectstack validate green. Each detail entry is now a strict shape of the twelve keys the renderer reads, and its columns references InlineGridColumnSchema. Page-component properties is read by the component-props gate, which reports a failing entry or column as an advisory finding, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered; the authored census found nothing to respell. defineStack's identity-only check reaches the block wherever a page carries it, with the reach inline-grid-column-identity-only-currency-scale-refused records for the other two carriers. Its D3 record is the semantic entry ui-object-master-detail-form-details-closed. It closes the fourth carrier the same way: record:line_items had no ComponentPropsMap row — it was the one entry on the string-arm registration ledger — so the component-props gate skipped its props, and the showcase project page's five field-keyed columns published green over a grid of empty cells. The row declares the fifteen keys the renderer reads, requires relationshipField and at least one column, and its columns references InlineGridColumnSchema; the showcase columns are respelled name in the same change. The panel draws its columns as authored, with no hydration from the child object's field, so defineStack's identity-only check does not reach it. Its D3 record is the semantic entry ui-record-line-items-props-closed. It also holds an ADR-0021 dataset's field — dimensions[].field and measures[].field — to the accept set the cube members it compiles to already hold, from one shared declaration: a field of the dataset's object, a relationship path ending in one, and on a measure also '*' (ADR-0021 "zero raw SQL / zero raw expressions"; ADR-0049 enforce-or-remove). The slot was a bare string that parsed any expression, while the analytics dataset door already refused one on every query, so an expression could be saved and never answered. It is now refused at parse with a prescription naming the ADR-0021 form — a measure with its own structured filter, or derived: { op, of: [...] } over named measures — and so are an empty string (a count omits field instead) and '*' on a dimension, which names no axis. The one lossless repair is D2: dataset-count-measure-empty-field-removed drops a count measure's empty field, which still counts rows. An expression has no mechanical rewrite into a column, so the semantic entry dataset-member-field-expression-refused carries the rest. It also closes the export options of an object-grid page block. exportOptions was z.unknown(), so a bare format array — the list view's legacy spelling, which the list view lifts to { formats } — was accepted on the grid, whose renderer reads exportOptions.formats and lifts nothing: the export menu offered its csv/json default and the author's list was dropped. The row now takes the list view's five-member export options object by identity, not the list view's union, and refuses a bare array with the object form named, a format outside the enum and an undeclared key. Page-component properties is read by the component-props gate, which reports these as advisory findings, and is not parsed on the metadata save or load path, so a stored page still saves and loads and no conversion is registered: the bare array never worked here, and lifting it would change the menu a deployed grid shows. The authored census found nothing to respell. Its D3 record is the semantic entry ui-object-grid-export-options-closed. It also makes an agent's structured output JSON-only (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, enforces structuredOutput on every final answer and refused four of its members before an agent's first turn: the regex, grammar and xml formats — no key ever carried a pattern or grammar to check against, and an answer is checked only as JSON — and the coerce_types step, for which no coercion engine exists. All four are refused at parse with a prescription, and the D2 conversion agent-structured-output-refused-members-removed deletes a block whose format was retired, deletes a retired fallbackFormat and drops coerce_types from the pipeline, retired from the load path. It also retires the metric sub-caption at both ends (maintainer ruling 2026-10-01, which reverses the 2026-08-06 ruling that gave it a translation key of its own; ADR-0049). The widget translation key dashboards.<name>.widgets.<id>.subCaption overlaid a widget's options.description, a key the dashboard schema never declared and no authored widget wrote, so the overlay in translateDashboard was its only writer. The overlay is removed, subCaption is a retiredKey() tombstone on the widget translation node, and its former subtitle alias now carries the retirement instead of a rename onto a key that accepts nothing. A widget keeps one authored description, widget.description, which renders as the card-header subtitle and is translated by the widget's description key. The D2 conversion translation-widget-sub-caption-removed strips the key from bundle entries and stored translation items as a lossless delete of what is served, retired from the load path so authors are refused at parse; its D3 record is the semantic entry translation-widget-sub-caption-retired. It also makes an agent's memory contract state exactly what the runtime honours (ADR-0049 enforce-or-remove). The cloud AI runtime, which executes agents, recalls the newest maxEntries long-term notes before the first round, writes one every reflectionInterval delivered interactions, and keeps them in its own database store; before an agent's first turn it refused the vector store (the old default) and redis, an enabled longTerm missing either number, and a reflectionInterval without one. So longTerm.store is retired as a whole key — the memory store is platform infrastructure, not agent metadata — and the D2 conversion agent-memory-long-term-store-removed deletes it, losslessly, retired from the load path; and with long-term memory enabled both numbers are required at authoring, with no default declared, so an upgrading author chooses them. It also retires an agent's conversation state machine, agent.lifecycle (ADR-0049 enforce-or-remove). It was parsed and never read: no runtime moved an agent through a declared state or refused an undeclared transition, and enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected. What it reached for is served elsewhere — a conversation phase is a skill selected by its triggerConditions, a multi-step process is a Flow, a record's status transitions are the state_machine validation rule — so authoring refuses the key with that prescription, and the D2 conversion agent-lifecycle-removed deletes it, losslessly, retired from the load path. The XState StateMachineSchema family, kept by ADR-0020 only for this door, left the package with it. It also retires a cube measure's custom-SQL-expression types — number, string and boolean from AggregationMetricType, so from measures.<metric>.type (ADR-0049 enforce-or-remove). They marked a measure whose sql was the whole computation, and with that sql now a column reference they had nothing left to compute: the raw-SQL path returned the column unaggregated and the ObjectQL path refused the measure. Each is refused at parse with a prescription naming the six aggregates. No D2 conversion: the column alone does not say which aggregate the author meant, so the semantic entry cube-metric-expression-types-retired carries the choice, and a stored cube that still carries one is refused rather than rewritten. It also retires object-grid's resizableColumns (ADR-0049 enforce-or-remove; objectui's ruling that resizable is canonical, under the startup rule of immediate retirement): the legacy second spelling of resizable, read only as schema.resizable ?? schema.resizableColumns (measured at the .objectui-sha pin 89cad75d55, plugin-grid/src/ObjectGrid.tsx:5361). One switch, two spellings, and zero writers in either repository, so there is no window. A retiredKey tombstone on ObjectGridPropsSchema with one D2 conversion that follows the renderer's precedence: the value moves to resizable when that is absent, and strips as a lossless delete when it is present (it was never read then). Its D3 record is the semantic entry object-grid-resizable-columns-retired. It also types seven members of an object-grid page block: rowHeight, rowColor, navigation, conditionalFormatting, bulkActionDefs, aggregations and operations were z.unknown() (an array of it for bulkActionDefs), although the grid reads each with one shape, so rowHeight: 42 passed every door and rendered as compact. The five a list view also declares take the list view's own schemas by reference; aggregations takes the measured [{ field, type }] with the query AST's aggregation functions, and operations the four booleans a grid read point names (create, update, delete, export), refusing read and import, which nothing reads. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-grid-row-members-typed. It also types navigation on the object-map, object-gantt and object-tree page blocks (the first stage of the ComponentPropsMap z.unknown() close-out): each renderer hands it to the shared navigation hook, which reads navigation.mode and falls back to page, so navigation: 42 and a bare mode string passed every door and opened the record page. The three rows now take the list view's NavigationConfigSchema by reference, the carrier the grid, kanban, calendar and timeline blocks already take. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-map-gantt-tree-navigation-typed. It also retires the ai:chat_window page element (ADR-0049 enforce-or-remove), the user:profile shape one namespace over: no renderer for it ever shipped, and none is wanted — the console leaves it unregistered on purpose, because the floating chat overlay it mounts on every page is the supported AI chat entry point — so a page that placed one validated clean and drew "Unknown component type", and its four props configured nothing. The name leaves PageComponentType and is refused by name at the node, its ComponentPropsMap row stays as a whole-bag refusal carrying the same prescription, and the props def AIChatWindowProps is unpublished. No conversion is registered: the only edit is deleting the node, a layout decision that is the author's. Its D3 record is the semantic entry ui-ai-chat-window-retired; ai:suggestion is unchanged. It also narrows page requires to the kinds whose source is compiled at save (ADR-0080 §5; maintainer ruling 2026-10-03, letter A): the plugin-namespace list is derived from an html page's source when the page is saved, while on react, full and slotted pages nothing derived it, the Studio page editor dropped it on every save, and a load-time warning was its one reader. PageSchema now accepts the key only when kind is html or its deprecated alias jsx, and refuses it at requires on every other kind, a page that omits kind included, naming the key, the page's kind and the compiled kinds. The key stays live on html pages, so there is no tombstone. The D2 conversion page-requires-non-compiled-kind-removed deletes the key from those pages, retired from the load path, so stored rows and artifacts replay clean while authored sources are refused until edited; the delete is lossless. Its D3 record is the semantic entry page-requires-non-compiled-kind-refused. It also types eight list members of the object-grid, object-kanban and object-calendar page blocks (the second stage of the ComponentPropsMap z.unknown() close-out): the grid's fields, selection, selectable, rowActions, bulkActions and batchActions, the kanban's columns and the calendar's calendar were z.unknown() (an array of it for the lists), although each renderer reads them with one shape, so a { name } entry in bulkActions passed every door and was skipped. The members a list view declares take the list view's own by reference (batchActions, the spelling the grid reads first, takes bulkActions's); the grid's fields and selectable and the kanban lane take the measured shape. The grid's columns stays open: its group headers draw an authored column's options, which the list view's column entry does not declare. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-grid-kanban-calendar-list-members-typed. It also refuses, at parse, a hook whose body targets a table of stored metadata, sys_metadata or sys_metadata_history (maintainer ruling 2026-10-03, letter A: an app-authored body may not touch those tables, whose only writer for a body is the metadata protocol). The runtime already refused such a hook where a body becomes a handler, so it never ran, while the metadata save door answered 200 for it. HookSchema now refuses the same set at object, or at the list member, with the runtime's prescription to change metadata through the metadata API, judged by the one predicate the runtime uses: a hook with a body in any form whose target names either table. A code handler and the wildcard '*' stay outside it, as they are at registration. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could keep. Its D3 record is the semantic entry hook-body-stored-metadata-target-refused. It also types four members of the object-form page block (the third stage of the ComponentPropsMap z.unknown() close-out): contentLayout, submitBehavior, navigateOnSuccess and mobile were z.unknown(), although the form reads each with one shape, so a submitBehavior kind the form does not know passed every door and fell through to the thank-you panel. submitBehavior takes the form view's own block by reference; the other three take the measured shape. The form's fields and sections and the master-detail form's two stay open — the form draws a { name } field entry and an inline runtime field inside a section, which the typed shapes would refuse — and customFields stays open until the spec declares the runtime form field its entries are. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-form-members-typed. It also completes the element:text variant convergence (the second release of the ruled two-release split): the enum is the nine values ui:text publishes — h1-h6, body, caption, overline — and the pre-convergence spellings heading and subheading, which every release since the nine were added still accepted, are refused by name with a prescription naming the level to write. The D2 conversion element-text-variant-heading-levels rewrites heading to h2 and subheading to h3 on every element:text page component — the heading element each one always rendered, so the outline is unchanged and the heading takes that level's style. The body default for an absent variant is unchanged. It also types two members of the object-metric page block (the fourth stage of the ComponentPropsMap z.unknown() close-out): aggregate and trend were z.unknown(), although the tile reads each with one shape, so aggregate: 'count' and a trend with no value passed every door, and the tile asked the server for a measure it does not have, or painted a lone %. aggregate takes the query AST's aggregation functions and the chart aggregate's groupBy union by reference, with groupBy optional because a metric is one number; trend takes the badge's measured shape. drillDown and compareTo stay open: each by-reference candidate declares a key the tile never reads (the chart drill-down's filter, the dashboard comparison's dimension), and the chart drill-down refuses the report the tile draws, so each waits on a ruling. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-metric-aggregate-trend-typed. It also retires an object-master-detail-form detail entry's sortField (ADR-0049 enforce-or-remove; the spec half of objectui's own retirement of the override). The console stopped reading the authored override: the field its line grid stamps with each line's position on drag-reorder is derived from the child object — its first field named position, sort_order, sequence, line_no, line_number or sort — and the pinned console had crossed that change while the spec still declared the key, so an authored value published green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 conversion that is a pure lossless DELETE scoped by component type and by position (properties.details[]); its D3 entry object-master-detail-form-detail-sort-field-retired carries the one judgment left, whether the child object declares the field the line order is kept in. It also types the object-metric page block's compareTo (the fifth stage of the ComponentPropsMap z.unknown() close-out) to the tile's read, per the ruling between the reference and the read: { kind }, with kind the dashboard widget comparison's own vocabulary by reference, and dimension refused by name, because this inline tile shifts the date macros in its own filter and never reads a dataset time dimension. A bare kind string, a kind outside the two and a dimension passed every door and compared the wrong window. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-metric-compare-to-typed. It also types the object-metric page block's drillDown to the tile's read (the same stage and ruling): its five list members — enabled, title, target, columns, maxRows — are the chart drill-down's own by reference, and filter and mode are refused by name, because a metric tile has no click event for a drill filter to resolve against and no row for mode to open; both passed every door and were ignored. The drill report stays open: the tile draws a dataset-bound report, but the spec declares no drill report yet, and declares that contract first. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-metric-drill-down-typed. It also types the object-grid page block's columns (the fifth stage of the ComponentPropsMap z.unknown() close-out), the list member the second stage held: the grid's group headers drew a column's options, which the list view's column entry does not declare, and objectui has since retired that read and takes the labels from the object field only. So the member takes the list view's own columns by reference — all field names or all column entries — and a column keyed accessorKey / header / name, a mixed list or an undeclared column key (editable, options, reference), which passed every door and drew no column or was ignored, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-grid-columns-typed. It also refuses, at parse, a flow create_record, update_record or delete_record node whose objectName is the string sys_metadata or sys_metadata_history (the maintainer ruling of 2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only writer is the metadata protocol). The runtime already refused such a node before any write, at its first run, while every authoring door accepted the flow. FlowSchema now refuses the same set at nodes.N.config.objectName, through the one judge registerFlow and objectstack validate share, with the runtime's prescription to change metadata through the metadata API: one of those three write nodes whose objectName names either table by exact name. A get_record node and a dynamic target stay outside it: the run judges the name it hands the data engine. No key is removed, so there is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. Its D3 record is the semantic entry flow-write-node-stored-metadata-target-refused. It also types the top-level fields of the object-form and object-master-detail-form page blocks (the last stage of the ComponentPropsMap z.unknown() close-out), the two members the third stage held: the form drew a { name } field entry its own page-builder guide taught, with a label, type and required it silently dropped, and objectui has since retired that entry from every authoring face, drawing only a stored one by its name. So both rows take field names, objectui's own declaration of the member, and refuse an object entry with what to write instead — a { name } entry is its bare name, and a { field } entry belongs in a section. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-form-fields-names-typed. It also types the object-gantt page block's markers (the same stage): its entries were z.unknown() because the marker contract lived only in objectui, so a marker with no date, a numeric date or a misspelled member passed every door and the chart drew no line, or drew it unlabelled. The spec now declares objectui's own authoring declaration of a marker, { date, label?, color? } with date a string, and the row takes it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-gantt-markers-typed. It also types the object-timeline page block's mapping (the same stage): the binding record — four optional field names for an entry's title, date, description and marker colour — was z.unknown() because its contract lived only in objectui, so a bare field name or a misspelled member passed every door and the rail drew the default field. The spec now declares objectui's own declaration of it, and the row takes it. The stage's other members — the metric drill-down's report, the form's customFields and both forms' sections, the timeline's items and the action containers' members — stay open: each contract has more than one viable shape that no ruling decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-timeline-mapping-typed. It also types the object-kanban page block's conditionalFormatting, the one member the ComponentPropsMap z.unknown() close-out held for a ruling: it was z.unknown() while objectui's kanban also authored a native rule dialect the list view refuses, so 42 or a rule with no style passed every door and the board painted no card for it. objectui has since made the list view's { condition, style } rule the member's only authoring dialect, and the board evaluates it with the grid's evaluator, so the row takes the list view's own member by reference, as object-grid does. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-kanban-conditional-formatting-typed. It also requires every block of a joined report to bind a dataset (ADR-0021 single-form, enforced under ADR-0049 enforce-or-remove): the schema comment and the reports guide both said each block is dataset-bound, but the joined arm of ReportSchema's refinement required only a non-empty blocks, so a block with no dataset parsed, passed objectstack validate and every save door, and drew nothing: the joined renderer issues no query for it, and a report whose blocks all lack one falls through to the pre-9.0 presentation bridge, which issues none either. The arm now refuses each such block at blocks[i].dataset, naming the block, with the prescription to bind it to a dataset; dataset stays optional on the block shape, which is read only on a joined report. No key is removed, so there is no tombstone, and no D2 conversion exists: only the author knows which dataset a block was meant to show. Its D3 record is the semantic entry ui-report-joined-block-dataset-required. It also types the object-form page block's customFields, one of the two contracts the ComponentPropsMap z.unknown() close-out held as forks and the maintainer has since ruled: each member is the runtime form field the form draws, which the spec did not declare, so a member with no name or a misspelled member passed every door and the form drew the field without it. The spec now declares a closed runtime form field of the members the form draws, in camelCase, keyed by name — the grid widget's snake_case keys stay out until the widget reads a camelCase spelling — and the row takes a list of it. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-form-custom-fields-typed. It also types the sections of the object-form and object-master-detail-form page blocks, the other ruled fork: a section's fields draws an inline runtime form field beside a name and the form view's { field } entry, which the stored form view's section refuses, so the sections stayed z.unknown() and a misspelled key passed every door. Both rows now take one page-block section shape of their own — the form view's section keys plus those three entry arms, the inline arm the runtime form field — in canonical spellings only: a page block's properties is never parsed on the way to the form, so a deprecated section visibleOn or a string columns, which a form view folds at parse, was dropped, and is refused with the canonical spelling. The stored form view is unchanged. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-form-sections-typed. It then types the three members the stages above held open, as the maintainer ruled them on the decision card for those forks. The object-metric drill-down's report is ReportSchema, by reference (fork 1, letter B): it waited until a joined report refused a block that binds no dataset, and since then every report the member admits is one the drill drawer draws — a report with no dataset, a bare report name or a { name } reference, which the drawer answered by listing the records, is refused. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-metric-drill-down-report-typed. It types the object-timeline page block's items (fork 4, letter B): each entry is one of objectui's two ruled kinds, closed — a feed entry { time, title, description, variant, icon, content, className } or a gantt row { label, items } of bars { title, startDate, endDate, variant }, each date a string or epoch milliseconds — and a row refinement pairs each entry with the kind the block's variant selects, so a feed entry with no title, or a gantt row on a feed timeline, is refused instead of drawn empty. A feed entry's content (child components) is held unjudged until a writer appears. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-object-timeline-items-typed. And it types the members of the action:group and action:menu page blocks, the last of those forks (the same card, fork 5, letter A): each member was an open record the container draws and runs itself, so a misspelled key, a node-style actionType or an endpoint no api handler reads passed every door. A member now takes action:button's keys with its executor spelled type, measured from the containers' reads — an action:menu item reads no size and declares none — with the rows' prescriptions; outcomeMessages, a member className and a member properties.params are refused, and outcomeMessages stays undeclared on all four action blocks as one decision. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-action-group-menu-members-typed. It then closes the one static-values spelling those members still accepted and the containers drop: an action:group or action:menu member's params takes the input list, an ActionParam[] array, only, unless the member's type is api, whose object params keeps its request-payload window. params carries one shape and no second value-bag key is declared, so an object params on any other member, which parsed and then reached no action, is refused at actions.N.params with the prescription to author an action with static parameter values as its own action:button node. Read by the component-props gate (advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record is the semantic entry ui-action-group-menu-member-params-array-only. It also judges an approval flow node's config at parse against the contract the spec declares for it, ApprovalNodeConfigSchema, WHOLE. The approval executor fails the node on any issue of that contract, while objectstack validate and objectstack compile exited 0 on an undeclared escalation.bogusKey or a timeoutHours: 0.5 and compile copied it into the artifact. The approval node now joins a declared contract map beside the builtin executor contracts, read by the one judge registerFlow and objectstack validate share, with no plugin loaded: an undeclared key or a refused value is refused at nodes.N.config.<key> in the contract's own words, its did-you-mean included, and a key left out as before. The builtin arm stays presence-only. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what the author meant. Its D3 record is the semantic entry flow-approval-node-config-contract-refused. Then the builtin arm stops being presence-only: a present value a builtin node's executor contract refuses is refused at parse, at nodes.N.config.<key>, with the same node-config-refused-by-contract code. Every builtin executor parses its config against that contract before it acts, so a create_record outputVariable: 42 or a screen field min: '1' used to pass objectstack validate and objectstack compile, register, and fail every run that reached the node. The arm judges only what the build can know the run will parse: never a value carrying a {token}, whatever its slot's type (held back by ruling, not admitted: outside http such a token in a number or boolean slot still fails at its first run, so those slots take a literal); on http, which parses after interpolating, only token-free values and never the credential-held signingSecret; on a loop, only one with a body; on the region containers, never the region slots. Key membership is untouched. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know the value the author meant. Its D3 record is the semantic entry flow-builtin-node-config-values-refused. It also retires the flat-list form of a package manifest's permissions (ADR-0049 enforce-or-remove): ManifestPermissionsSchema was a union of a list of permission strings and the structured ADR-0025 block { services, hooks, network, fs }, and nothing ever acted on the list — the loader registers the consented grant set, never the manifest's request — so the block is now the only form. A list is refused at parse with its prescription, and the D2 conversion manifest-permissions-string-list-removed strips it from the stack's manifest and every packages[].manifest as a lossless delete, retired from the load path; translating what each dropped string meant into the four lists is the author's judgement, not a rewrite. It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this protocol by D7). On indexes[].unique, bare true was the one spelling whose scope was positional: it built the index over exactly fields, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. The parse now refuses it with a prescription naming both words — 'global' (installation-wide, the index bare true built) and 'organization' (one holder per organization). Field-level unique: true is untouched. The D2 conversion declared-index-unique-scope rewrites a declared index's bare true to 'global', which is lossless and drift-free by construction, retired from the load path so authors are refused at the door while stored rows, built artifacts and os migrate meta replay it. Its D3 record is the semantic entry declared-index-bare-unique-true-retired: whether each respelled index was really meant installation-wide is the author's call. It also takes the injected organization column off seven deployment-level platform tables — sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal and sys_presence (ADR-0131 D7). A writer census found no writer that attributes a row of any of them to an organization, so the column only ever held NULL, and under a walled posture the tenant wall hid every row from every reader. Each now declares systemFields: { tenant: false } and the object-level capability gate requiredPermissions: ['manage_platform_settings']: with no column there is no wall, so reads are governed by object permission, and the gate keeps one organization's administrator off another organization's rows. Nothing in stack metadata is rewritten; an existing database keeps the column as an orphan the boot drift report names, and os migrate apply --allow-destructive drops it. The D3 records are the seven sys-*-organization-column-retired semantic entries. It also refuses, at parse, a flow edge that does not resolve in its own graph or that repeats an earlier one. An edge's source and target must name nodes of the graph that declares it — the flow's own nodes, or the region body's for an edge inside a region — because the engine resolves them there alone, and a dangling edge carried the run nowhere, silently; and an edge with the same source, target, type, condition and branch label as an earlier edge of that graph is refused, because the engine runs a target once per out-edge it selects and a copy ran it again. Both are judged in the region walk the node-id rule uses, so objectstack validate, registerFlow and the metadata save door agree. No key is removed, so there is no tombstone, and no D2 conversion exists: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. Its D3 record is the semantic entry flow-edge-unresolved-or-repeated-refused. And the builtin arm judges key membership where no other door does: a key a script or subflow node's executor contract does not declare is refused at parse, at nodes.N.config.<key>, with the same node-config-refused-by-contract code. Those two descriptors publish no configSchema, so registerFlow's undeclared-key check skipped them, while their executors parse the strict contract and refuse the node on an undeclared key: a script bogusKey used to pass objectstack validate, objectstack compile and registration and fail every run that reached the node. Every other builtin keeps its undeclared keys at registration, against its descriptor; a retired script key keeps its tombstone. No key is removed, so there is no tombstone, and no D2 conversion exists: the platform cannot know what an undeclared key was meant to be. Its D3 record is the semantic entry flow-script-subflow-config-undeclared-keys-refused. It also moves the settings cascade's global rung out of the tenant-scoped sys_setting into the new tenant-less sys_platform_setting (ADR-0131 D7): one row per namespace and key for the deployment, no organization column, reads governed by the manage_platform_settings capability. The settings service writes a global-scope key there and reads the rung from there alone, and the global option of sys_setting.scope retires because no write reaches it. The cascade order and the global resolution source are unchanged. Nothing moves automatically: the v18 upgrade ceremony moves existing global rows, sys_secret handles included, and they open unchanged because the ADR-0128 AAD binds no holder object and no organization. The D3 record is the sys-setting-global-rung-moved semantic entry. It also retires the document family WHOLE (ADR-0049 enforce-or-remove; the ruling of record on PDF and print documents, letter B′, 2026-10-08: "A document is a page with a print declaration; no new template type"): the four defs of data/document.zod.ts — data/DocumentTemplate (a docx template with placeholders), data/Document, data/ESignatureConfig and the orphaned data/DocumentVersion — exported from @objectstack/spec/data, mounted by no stack key, registered as no metadata type and read by nothing in this repository, objectui or hotcrm, leave via RETIRED_DEFS_BY_MAJOR with one D3 semantic entry, so that "template" means one thing: a printable document is a page that declares print. The ESignatureConfig deadline-key tombstones leave with their def's source and their RETIRED_KEYS_BY_MAJOR[18] entries stay as history. It retires the single-brace {…} template dialect from the flow VALUE slots (the C half of the maintainer's ruling D on the flow expression dialects): the assignment node's values, in all three shapes, and the fields map of create_record and update_record, where a CEL value envelope is already the expression form. A string there is now the literal text it spells, and one carrying a {…} token is refused — by FlowValueSlotSchema, registerFlow, objectstack validate and the executor alike — with the CEL spelling of each token. No D2 conversion exists: every authored spelling was measured lossy (an absent key writes nothing under the template and fails under CEL; CEL divides two integers as integers), so which value an absent key should write is the author's judgment. The $User paths are refused too: the flow CEL scope binds current_user, the run's user or null in a run with none, so {$User.Id} is current_user.id, guarded where a flow can run without a user, and the other $User paths, which never resolved, name a read of the user record. The date macros keep their meaning until CEL can spell them. Its D3 record is the semantic entry flow-value-slot-template-dialect-refused. It also takes the injected organization column off the compliance ledger, sys_audit_log (ADR-0131 D7): some of its rows are about deployment-level actions no organization owns, so the organization a row is about stays in the attribution field tenant_id, which every writer already stamps, and never becomes the tenancy anchor. With no column there is no wall, so a platform administrator now reads the rows about no organization too; an organization reader is scoped to the rows about its active organization by the platform row policy sys_audit_log_org, stripped when no wall is enforced, and organization_admin names the ledger without the superuser bits so its wildcard bypass cannot skip that policy. Per-tenant retention partitions on tenant_id. Nothing moves automatically: an existing database keeps the column as an orphan the boot drift report names, for the v18 ceremony to drop once its values are confirmed in tenant_id. The D3 record is the sys-audit-log-organization-column-retired semantic entry. Then the builtin key arm covers every builtin whose contract registration could judge: a key the executor contract of a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel node does not declare is refused at parse, at nodes.N.config.<key>, with the same node-config-refused-by-contract code, closed with the rename-or-remove remedy. Registration's descriptor walk refused those keys already, after objectstack validate and objectstack compile had passed them, and it now stands aside for those types, so each has one judge; the declared key sets were measured equal first, so registration refuses what it refused before. try_catch waits for its contract's retry to close (below): it stripped an unknown key where its descriptor closes it. No key is removed, so there is no tombstone, and no D2 conversion exists. Its D3 record is the semantic entry flow-builtin-node-config-undeclared-keys-refused. It executes ADR-0032 Decision 3 in the flow TEXT slots — a notify node's title and message, a screen node's title and description, a refusing end node's message: they render through the formula template engine, so their placeholders are {{ }} holes, a variable path with an optional formatter (the engine's hole grammar now admits a $-named variable, so {{ $error.message }} is a hole). A single-brace {…} token there is refused — by the node contract, registerFlow and objectstack validate alike — with the hole spelling of each path token, or, for arithmetic, a function, a date macro or a run-user path, the assignment that computes it into a variable. No D2 conversion exists: the 17.x interpolator and the engine render a Date differently (JSON-quoted against ISO text), and a whole-slot object differently in a screen or end text, so the rewrite is the author's to check. Every other flow string keeps the single-brace dialect. Its D3 record is the semantic entry flow-text-slot-single-brace-refused. It also makes the deployment's platform-global declaration total (ADR-0131 D7): an object a deployment declares platform-global in its org-scoping service's platformGlobalObjects gets no organization column on that deployment, because the injected-columns plan reads the declaration, so the organization wall and the driver agree by having nothing to scope. The engine reads it at its plugin start, before the first schema sync, once every plugin init has run, and re-plans the objects registered before it; the security layer's stand-down for such an object retires with it. An absent declaration changes nothing, and a malformed one is refused and declares nothing. Nothing moves automatically: a declaring deployment's existing table keeps the column as an orphan the boot drift report names. The D3 record is the platform-global-object-organization-column-retired semantic entry. It also retires the sys_view_definition platform object as inert (ADR-0131 D13): no framework code wrote or read its rows, and runtime-authored views are view items in sys_metadata. The object, its two registrations, its kernel:ready active-row index migration and that migration's exports leave, and its name leaves the platform-object registry. Nothing in stack metadata is rewritten; an existing database keeps the table, which no platform path drops. The D3 record is the sys-view-definition-retired semantic entry. Then the retry policy closes, and try_catch joins the builtin key arm: RetryPolicySchema, the one declaration behind job.retryPolicy and a try_catch node's retry, refuses a key it does not declare, naming it with a did-you-mean, where it used to strip it — and with opt-in defaults a stripped maxRetries meant no retry at all. No writer relied on the strip. With retry closed to the five keys the descriptor declares, a key a try_catch node's contract does not declare is refused at parse, at nodes.N.config.<key>, with the same node-config-refused-by-contract code, and the descriptor walk keeps plugin node types only. A retryDelayMs the conversion leaves beside a different backoffMs meets its tombstone there, as it met the walk. No key is removed, so there is no new tombstone, and no D2 conversion exists. Its D3 record is the semantic entry try-catch-and-retry-policy-undeclared-keys-refused. In those same text slots a {{ }} hole may root at a $-named variable only when the flow engine binds it ($record, $runId, $flowName, $flowLabel, $error, and a flat-graph loop's $loopItems / $loopIndex): a hole such as {{ $User.Id }}, which the 17.x contracts accepted as a plain string and which renders nothing under the template engine, is refused by the node contract, registerFlow and objectstack validate with the remedy its single-brace spelling gets — compute the value with an assignment node, then write the variable as a hole. The template engine binds no new variable, and no D2 conversion exists: what the hole was meant to read is not in the flow. Its D3 record is the semantic entry flow-text-slot-unbound-dollar-root-refused. The binding keys follow the same rule, so a flow cannot bind a $ name it is then refused to read: a node's outputVariable (get_record, create_record, map, script, subflow) refuses a name that starts with $, and a try_catch errorVariable refuses every one but the engine's own $error, its default. The remedy is the same name without the $, read as {{ name }}. Each key states the rule as a pattern, so the published JSON Schema refuses what the parse refuses, and the node contract, registerFlow, objectstack validate and the run itself refuse such a name at the key. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks, so the rename is the author's. Its D3 record is the semantic entry flow-binding-variable-dollar-name-refused. It also refuses a page that authors both slots.details and slots.tabs. The two slots read as independent and are not: details is the body of the Details tab, that tab lives inside the synthesized page:tabs strip, and tabs replaces the whole strip, so the console's synthesizer never read a details authored beside it and its sections and hidden fields silently never applied. PageSchema now refuses the pair at slots.details, naming both slots and the fix — the record:details component as the children of a tabs item — so objectstack validate and the metadata save door agree; the platform's own sys_user_detail page moved its details body into its first tab. No key is removed, so there is no tombstone, and no D2 conversion exists: which tab carries the body is the author's decision. Its D3 record is the semantic entry page-slots-details-beside-tabs-refused.

Mechanical (applied for you)

ConversionSurfaceChangeLoad window
field-malformed-scale-precision-removedobject.fields.*.scale / object.fields.*.precisionmalformed field 'scale'/'precision' declarations (non-integer or negative) are removed — they were silently unenforced; the schema now refuses them at authoringretired — migrate meta only
record-chatter-position-vocabularypage.component.record:chatter.position / page.component.record:discussion.positionrecord:chatter / record:discussion 'position' respelled to the renderer's vocabulary — 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (one vocabulary, the renderer's, rather than a mapping layer between two: the renderer compares only bottom/right/left, and the old set fell through every branch)retired — migrate meta only
element-input-target-variable-removedpage.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariabletext-input/record-picker component prop 'targetVariable' removed (retired under ADR-0049 enforce-or-remove as a declarative hint nothing read; the live binding resolves from the page variable whose source names the component id)retired — migrate meta only
element-filter-removedpage.component.element:filter.object / page.component.element:filter.fields / page.component.element:filter.targetVariable / page.component.element:filter.layout / page.component.element:filter.showSearch / page.component.element:filter.ariathe whole 'element:filter' element retired (ADR-0049 enforce-or-remove at element grain, not key by key — no renderer for it ever shipped in any repo, so every key was a capability claim nothing kept; list surfaces own their filtering via a view's userFilters / the list filter builder). All six props are stripped; the bare node the conversion leaves is refused by name at the parse, with the prescription to delete the componentretired — migrate meta only
element-form-removedpage.component.element:form.object / page.component.element:form.fields / page.component.element:form.mode / page.component.element:form.submitLabel / page.component.element:form.onSubmit / page.component.element:form.ariathe whole 'element:form' element retired (ADR-0049 enforce-or-remove at element grain, not key by key — no renderer for it ever shipped in any repo, so every key was a capability claim nothing kept; use the object-bound 'object-form' block instead — rendered and designer-publishable). All six props are stripped; the bare node the conversion leaves is refused by name at the parse, with the prescription to delete the componentretired — migrate meta only
field-column-lists-canonicalizedfield.inlineColumns[].field / field.relatedListColumns[] object entriesinline-grid column entries respelled 'field' → 'name' (the declared spelling wins, and the grid renderer now reads 'name' too) and related-list column objects folded to their child field-name string (both lists were z.any(), so a mis-keyed column published clean and rendered blank cells; inline columns now take a strict name-keyed shape and related-list columns plain field names, so a mis-keyed column is refused at publish)retired — migrate meta only
metric-filters-removedanalyticsCubes[].measures.<metric>.filterscube metric key 'filters' removed (ADR-0049 — no strategy ever read it: the authored raw-SQL condition was parsed and dropped, and the query returned the unfiltered aggregate. Filter at query time with where, or use an ADR-0021 dataset measure's structured filter; a metric's own sql is a column reference)retired — migrate meta only
cube-sub-day-granularities-removedanalyticsCubes[].dimensions.<dim>.granularitiescube dimension granularities 'second' / 'minute' / 'hour' removed (ADR-0049 — no backend bucketed them and none could advertise them: supports.queryDateGranularity is a record over DateGranularity, which declares day, week, month, quarter, year. Offer the coarsest interval that still answers the question)retired — migrate meta only
cube-join-sql-and-relationship-removedanalyticsCubes[].joins.<alias>.sql / analyticsCubes[].joins.<alias>.relationshipcube join keys 'sql' and 'relationship' removed (ADR-0049 — neither was ever read: both strategies synthesise the ON clause as a foreign-key equality, so an authored join condition was REPLACED under a 200 and a declared cardinality changed no SQL. Keep joins.<alias>.name alone; the record KEY is the foreign-key field on the base object)retired — migrate meta only
record-highlights-field-icon-removedpage.component.record:highlights.fields[].iconrecord:highlights highlight-field key 'icon' removed (ADR-0049 — no render path: the highlight chip has no icon slot, the register hook carries field names only, and the Studio designer publishes the field list as plain strings, so an authored icon was accepted and drawn by nothing)retired — migrate meta only
mapping-lookup-params-removedmapping.fieldMapping[].params.object / .fromField / .toField / .autoCreatemapping lookup params 'object'/'fromField'/'toField'/'autoCreate' removed (ADR-0049 — the import path never read them: lookup copies the cell through and reference resolution runs off the target field's own metadata. autoCreate never created anything — an unresolved reference fails the row either way. Implementing them instead would have added a second reference-resolution dialect to the import path)retired — migrate meta only
translation-component-submit-label-removedtranslation.pages.components.submitLabeltranslation component-copy key 'submitLabel' removed (retired rather than re-anchored — its only declared carrier, 'element:form', retired whole because no renderer for it ever shipped, so the resolver no longer overlays it and a stored string was read by nothing; the live form surface's submit copy is 'object-form''s 'submitText', localized at its own authoring site, and re-anchoring the key there would only have added a second place to translate one word)retired — migrate meta only
page-component-responsive-removedpage.components[].responsivepage component key 'responsive' removed (ADR-0049 enforce-or-remove — no renderer ever applied per-component breakpoint layout overrides, and the shared ResponsiveConfig shape leaves with its last carrier; use responsiveStyles (ADR-0065) for breakpoint behaviour that IS applied)retired — migrate meta only
object-grid-default-sort-removedpage.component.object-grid.defaultSortobject-grid component prop 'defaultSort' removed (retired under ADR-0049 enforce-or-remove as the legacy single-sort second spelling of 'sort', read only when 'sort' was absent; the pair moves to sort: [{ field, order }], the array shape every read path honours)retired — migrate meta only
object-kanban-quick-add-removedpage.component.object-kanban.quickAddobject-kanban component prop 'quickAdd' removed (retired from the board under ADR-0049 enforce-or-remove — the affordance is gated on a host-supplied 'onQuickAdd' function no producer puts on an object-kanban node, so the key was accepted and dropped; delete the key — object-kanban offers no quick-add control)retired — migrate meta only
permission-allow-restore-purge-removedpermission.objects.<object>.allowRestore / permission.objects.<object>.allowPurgeobject-permission keys 'allowRestore' and 'allowPurge' removed (ADR-0049 — the restore/purge operations they claimed to gate have never existed, so granting the bits delivered nothing; dispatched destructive lifecycle verbs stay denied fail-closed. The keys return with the M2 lifecycle initiative, which builds undelete and purge together with the permission bits that gate them)retired — migrate meta only
form-view-option-default-removedview.form.sections[].fields[].options[].defaultform-view per-option 'default' removed from the FormView vocabulary (ADR-0049 declared-but-unenforced — nothing on the form path read it: the insert-path default falls back to the OBJECT definition's option list, and no form renderer seeds a value from a form view's. The object field option's 'default' stays enforced; declare the pre-selected choice there — field-level 'defaultValue', or 'default: true' on that field's own options entry)retired — migrate meta only
field-reference-to-aliasfield.reference_tofield key 'reference_to' → 'reference' (the legacy objectql runtime dialect for a lookup/master_detail target; normalising to the protocol is the server's job and the renderer only executes the protocol, so stored rows must serve the canonical spelling before objectui deletes its reference ?? reference_to fallback arms)retired — migrate meta only
connector-error-mapping-removedconnector.errorMappingconnector key 'errorMapping' removed (ADR-0049 — no engine ever mapped an external error through the rules, so the eleven nested keys configured nothing, and the rule-level userMessage shared its spelling with the live API-error channel while never being shown; deleting the block resolves that collision without a rename. The whole ErrorMappingConfig / ErrorMappingRule shape and the ConnectorErrorCategory enum went with it)retired — migrate meta only
connector-connection-timeout-ms-removedconnector.connectionTimeoutMsconnector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a deadline and cannot at the site it names: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase. The value only travelled — onto the reported def and the materialization fingerprint. Use requestTimeoutMs, which resilientFetch applies as each attempt's deadline, and bound the connect phase at a provider or gateway that can separate the phases)retired — migrate meta only
hook-timeout-to-timeout-mshook.timeouthook key 'timeout' → 'timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged)retired — migrate meta only
job-timeout-to-timeout-msjob.timeoutjob key 'timeout' → 'timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged)retired — migrate meta only
api-endpoint-cache-ttl-to-cache-ttl-secondsapis[].cacheTtlapi endpoint key 'cacheTtl' → 'cacheTtlSeconds' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, seconds, is unchanged, and the key stays GET-only)retired — migrate meta only
dashboard-refresh-interval-to-refresh-interval-secondsdashboard.refreshIntervaldashboard key 'refreshInterval' → 'refreshIntervalSeconds' (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, seconds, is unchanged)retired — migrate meta only
connector-resilience-keys-removedconnector.health / connector.status / connector.webhooksconnector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health probe or circuit breaker ever ran, nothing read an authored status (the runtime reports a computed state), and a webhook nested in a connector was never registered or delivered. The ConnectorHealth / HealthCheckConfig / CircuitBreakerConfig, ConnectorStatus and WebhookConfig / WebhookEvent / WebhookSignatureAlgorithm shapes went with them)retired — migrate meta only
connector-triggers-removedconnector.triggersconnector key 'triggers' removed (ADR-0049 — a connector trigger never started anything: the automation engine registered a connector's actions only, no polling loop read an interval and no receiver was driven by a webhook trigger. The ConnectorTrigger shape went with it, including the interval spelling renamed to intervalSeconds earlier in this step. Start the work from a flow that calls the connector's action instead: an api flow for an external event, a schedule flow for a scheduled pull)retired — migrate meta only
memory-persistence-auto-save-interval-to-msdatasource.config.persistence.autoSaveIntervalmemory datasource key 'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both the file and auto arms (a duration key carries its unit in its name, and this one's unit lived only in the description; the value, milliseconds, is unchanged)retired — migrate meta only
turso-config-timeout-to-timeout-msdatasource.config.timeout (turso)turso datasource key 'config.timeout' → 'config.timeoutMs' (a duration key carries its unit in its name, and this one's unit lived only in the description and a .meta() title no parse reads; the value, milliseconds, is unchanged)retired — migrate meta only
view-page-mount-removedview.list / view.listViews.* — the list-view type 'page' and its pageName bindinglist-view type 'page' and its pageName binding removed (retired rather than finished: the delegating render half was never built, so a page view fell through to the grid branch and drew an empty table; ADR-0049 enforce-or-remove)retired — migrate meta only
list-view-sort-string-clause-to-arrayview.list.sort / view.listViews.*.sort — the bare string sort clausethe bare string list-view sort clause becomes the { field, order }[] array (one sort orthography platform-wide, the array: objectui already refuses the string, so the schema stops minting documents its own consumer refuses)retired — migrate meta only
page-assigned-profiles-removedpage.assignedProfilespage key 'assignedProfiles' removed (ADR-0090 D2 deleted the Profile concept it was named for, and no renderer, route or read door ever enforced it — the page stayed open to everyone; ADR-0049 enforce-or-remove)retired — migrate meta only
chart-config-aria-removeddashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.ariachart config key 'aria' removed (ADR-0049 enforce-or-remove — no chart renderer ever applied it on either face, so declared ARIA attributes silently did not reach the DOM; the accessible name that IS applied is the sibling 'description')retired — migrate meta only
dashboard-widget-chart-config-structure-removeddashboard.widgets[].chartConfig.type / dashboard.widgets[].chartConfig.xAxis / dashboard.widgets[].chartConfig.yAxis / dashboard.widgets[].chartConfig.seriesdataset-bound dashboard widget chart-config keys 'type'/'xAxis'/'yAxis'/'series' removed (ADR-0021 — the dataset decides which series exist and which column each one reads; the widget's own 'type' is the chart family, and 'dimensions'/'values' are the selection, so an authored axis could only agree with the dataset or silently re-point a series at another column)retired — migrate meta only
translation-per-app-settings-removedstack.translations[].<locale>.settings / translation.settingstranslation group 'settings' removed from both application-authored faces, the per-app bundle entry and the registered translation item: settings copy belongs to the platform, and the two authoring doors of one application translation type accept one shape. It is keyed by SettingsManifest.namespace and only platform code declares a manifest. A per-app bundle entry could only fill gaps the platform's own bundle left in the one merged served tree, and was overwritten wherever both defined the key; a stored item OVERRODE the platform copy, because the runtime-authored layer is read over the shipped bundles. Overrides now give way to the platform copy, gaps fall back to the manifest literal, and the group stays on the PLATFORM bundle, PlatformTranslationDataretired — migrate meta only
object-tenancy-organization-field-removedobject.tenancy.organizationFieldobject tenancy.organizationField removed (ADR-0049 — the stamp-only column declaration was authorable by every application and declared exactly once in the whole protocol, on the platform's own credential table; the divergence moves to a platform-internal table in @objectstack/metadata-core and stops being a knob)retired — migrate meta only
page-component-filter-record-to-rule-arraypage.component.dataSource.filter / page.component.properties.filter (the object-* blocks, element:number, element:record_picker) / page.component.properties.defaultFilters (object-grid) — the record and single-level AST filter formsa record-form or single-level AST filter at a converged rule-array door becomes the [{ field, operator, value }] rule array wherever the mapping is lossless (flat keys → equals rules, { $op: v } → the mapped operator, AST comparisons → one rule each); a filter carrying $and / $or / $not or any part with no lossless rule spelling is left exactly as stored — reported as a TODO, which os migrate meta --stored lists — and is not the form its door declares (one filter orthography platform-wide, the rule array; the migration converts only what maps losslessly and names the rest, because flattening a combinator would silently change what a page selects)retired — migrate meta only
view-item-owner-hidden-removedview.owner / view.hidden — on the view item record ({ name, object, viewKind, config })view item keys 'owner'/'hidden' removed (ADR-0049 — declared on the view item record and stored verbatim, read by nothing: no view switcher ever filtered on hidden, and no per-user scope ever read owner, so a view marked as one user's was listed for everyone)retired — migrate meta only
report-joined-chart-removedreport.blocks[].chart / report.chart on a joined reporta joined report's 'chart' removed from its blocks and refused on the container (ADR-0049 enforce-or-remove: the joined renderer draws each block as a table and never read either, so the chart parsed and nothing was plotted; a non-joined report keeps its live 'chart')retired — migrate meta only
view-overlay-owner-hidden-removedview.owner / view.hidden — on a flattened view overlay ({ name, object, viewKind, …, no config })flattened view overlay keys 'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay door, retired the same way: declared, accepted by the write door and stored verbatim, read by nothing, so a hidden: true overlay hid no view and an owner scoped none)retired — migrate meta only
form-layout-inline-grid-to-verticalpage.component.object-form.layout / view.form.layout / view.formViews.*.layoutform 'layout' arms 'inline' and 'grid' rewritten to 'vertical' (ADR-0049 — no renderer ever gave either a behaviour of its own: every form presentation folded both to 'vertical'. Multi-column is 'columns', honoured under either layout, and is left untouched)retired — migrate meta only
currency-config-precision-removedobject.fields.*.currencyConfig.precisioncurrency field key 'currencyConfig.precision' removed (ADR-0049 — no renderer or runtime ever read it: an amount's decimal places are its currency's ISO 4217 minor unit, derived from the currency itself. Its ISO 4217 contradiction check and the default 2 baked into parse output went with it; the field-level precision is a total digit count and is untouched)retired — migrate meta only
permission-rls-tags-removedpermission.rowLevelSecurity[].tagsRLS-policy key 'tags' removed (ADR-0049 — nothing ever read a policy's tags and no mainstream platform tags a row-level policy; dropping it changes no access decision)retired — migrate meta only
action-aria-removedaction.aria / object.actions[].ariaaction key 'aria' removed (ADR-0049 enforce-or-remove — no action surface ever applied it; every renderer takes the accessible name from the action's required 'label', and the placing node's own 'aria' block names the region)retired — migrate meta only
cube-member-inner-name-removedanalyticsCubes[].measures.<metric>.name / analyticsCubes[].dimensions.<dimension>.namecube member key 'name' removed from measures and dimensions (ADR-0049 enforce-or-remove — nothing read it: every consumer resolves a member by its record KEY, published and queried as <cube>.<key>. The record key is the member's name; to rename a member, rename its key)retired — migrate meta only
flow-decision-mode-inclusive-explicitflow.nodes[].config.mode (decision)edge-branched decision with two or more conditioned out-edges and no mode: mode: 'inclusive' written explicitly (the traversal became exclusive, first match in declaration order, as mainstream engines treat a decision, and taking every true edge must now be declared; the key keeps the every-true-edge behaviour those nodes had, and the author deletes it where the branches partition)retired — migrate meta only
view-list-tabs-removedview.list.tabs / view.listViews.*.tabs — the list view's own tab definitionslist-view key 'tabs' removed (ADR-0049 enforce-or-remove — parsed and stored, drawn by nothing: no renderer ever mounted a tab bar for it, and the tab strip above an object's records is the saved-view switcher, which renders one tab per listViews entry; move each tab you want to a named list view)retired — migrate meta only
cube-refresh-key-removedanalyticsCubes[].refreshKeycube key 'refreshKey' removed, with its 'every' and 'sql' (ADR-0049 enforce-or-remove — nothing read it: no analytics result is cached, so a declared refresh cadence refreshed nothing. Delete the key; a refresh cadence is declared again when a result cache exists)retired — migrate meta only
time-default-utc-suffix-droppedobject.fields.*.defaultValue / action.params[].defaultValue / page.component.element:button.action.params[].defaultValue (type time)a time literal default's Z or zero-offset suffix is dropped, which names the same wall clock; a default with a non-zero offset is left as stored and reported as a TODO, because a time value carries no zone (ADR-0053 D-C1) and only its author knows which wall clock it meantretired — migrate meta only
page-header-breadcrumb-removedpage.component.page:header.breadcrumbpage:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the app shell's header draws the navigation trail)retired — migrate meta only
connector-sync-keys-removedconnector.syncConfig / connector.fieldMappingsconnector keys 'syncConfig' and 'fieldMappings' removed (ADR-0049 — no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so the latest_wins and soft_delete defaults resolved and deleted nothing. The DataSyncConfig, SyncStrategy, ConnectorConflictResolution and ConnectorFieldMapping shapes went with them. A sync is defined on its target instead: a mapping whose connectorSource names the connector it pulls from, with a job for the cadence)retired — migrate meta only
form-view-subform-columns-canonicalizedview.form.subforms[].columns[].field / view.formViews.<key>.subforms[].columns[].fieldform-view subform grid column entries respelled 'field' → 'name', the grid's column identity (the carrier accepted any value until it took the inline grid column contract; a relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)retired — migrate meta only
action-block-endpoint-to-targetpage.component.action:button.endpoint / page.component.action:icon.endpointaction:button / action:icon component prop 'endpoint' → 'target' on an api action — the rename ActionSchema already prescribes; the console's api handler reads target only. An endpoint on a block with no actionType or another one is left as stored and reported as a TODOretired — migrate meta only
form-field-public-picker-removedview.form.sections[].fields[].publicPickerform field 'publicPicker' removed (ADR-0087 D2 — the anonymous public-form record-search picker is retired: an anonymous public form no longer takes lookup, master_detail or user fields, and the anonymous lookup route is gone. Use a select field with static options, or put the form behind sign-in)retired — migrate meta only
dataset-count-measure-empty-field-removeddataset.measures[].field (aggregate count, empty string)a count dataset measure's empty field is removed: a count with no field counts rows, which is what the empty string compiled to, and a measure's field is now a column reference that refuses an empty stringretired — migrate meta only
agent-structured-output-refused-members-removedagent.structuredOutput.format / agent.structuredOutput.fallbackFormat / agent.structuredOutput.transformPipelineagent structured-output formats 'regex' / 'grammar' / 'xml' and the transform step 'coerce_types' removed: the AI runtime refused each before the first turn, because structured output is checked only as JSON and no coercion engine exists. A block whose format was retired is deleted, a retired fallback format is deleted, and the coerce step is dropped from the pipelineretired — migrate meta only
translation-widget-sub-caption-removedtranslation.dashboards.widgets.subCaptiontranslation widget key 'subCaption' removed: the metric sub-caption it overlaid onto the widget's 'options.description' is retired at both ends — the dashboard schema never declared that key and no authored widget wrote it, so the overlay was its only writer. A widget keeps one authored description, 'widget.description', translated by the widget node's 'description' keyretired — migrate meta only
agent-memory-long-term-store-removedagent.memory.longTerm.storeagent memory key 'longTerm.store' removed: the memory store is platform infrastructure, not agent metadata — the AI runtime keeps long-term memory notes in its own database store and refused the 'vector' default and 'redis' before the first turn. The key is deleted; every other memory key staysretired — migrate meta only
agent-lifecycle-removedagent.lifecycleagent key 'lifecycle' removed: the conversation state machine was parsed and never read — no runtime moved an agent through a declared state. The key is deleted; a conversation phase is a skill with triggerConditions, orchestration is a Flow, record transitions are a state_machine validation ruleretired — migrate meta only
object-grid-resizable-columns-removedpage.component.object-grid.resizableColumnsobject-grid component prop 'resizableColumns' removed (the legacy second spelling of 'resizable', read only when 'resizable' was absent, retires at once so 'resizable' is the one spelling; the value moves to 'resizable' when that is absent, and is deleted when it is present)retired — migrate meta only
page-requires-non-compiled-kind-removedpage.requirespage key 'requires' removed from react, full and slotted pages (a page with no kind is full) — the plugin-namespace list is derived from the source at save only on html / jsx pages; on the other kinds nothing derived or enforced it, and the Studio page editor drops itretired — migrate meta only
element-text-variant-heading-levelspage.component.element:text.variantelement:text 'variant' spellings 'heading' → 'h2' and 'subheading' → 'h3' (the vocabulary converged on the nine values ui:text publishes; each old spelling already rendered that heading element, so the outline is unchanged and the heading takes that level's style)retired — migrate meta only
object-master-detail-form-detail-sort-field-removedpage.component.object-master-detail-form.details[].sortFieldobject-master-detail-form detail entry prop 'sortField' removed (the console reads no authored value: the line grid stamps the field it derives from the child object, so the key was accepted and dropped; delete the key — the child object's own position field keeps the line order)retired — migrate meta only
declared-index-unique-scopeobject.indexes[].unique / objectExtensions[].indexes[].uniquedeclared-index bare unique: true → unique: 'global' (ADR-0120 D2 — the scope is stated, never positional; 'global' is exactly the index bare true built, so the physical index is byte-identical; field-level unique: true is not converted)retired — migrate meta only
manifest-permissions-string-list-removedmanifest.permissionsmanifest 'permissions' as a flat list of permission strings removed (ADR-0049 — no loader ever read the list, so dropping it changes no grant; the structured { services, hooks, network, fs } block is the only form, and a permission string has no mechanical mapping onto it)retired — migrate meta only

Semantic (delegated to you, with acceptance criteria)

  • action-aria-retired — action.aria / object.actions[].aria — the ARIA block on an action → The action's required label, which every action renderer uses as the accessible name (the visible button or menu-item text, and the aria-label of an icon-only action). To name the region that places the actions, the aria block of the placing node — page.components[].aria or the list view aria.
    • Why not automatic: The D2 conversion action-aria-removed deletes aria from every stack action and every object-nested action, and the delete is lossless: no surface that renders an action ever read the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote aria.ariaLabel believed screen-reader users heard that name; they heard the label. The strip deletes the text along with the key, and only the author can say whether it should become the label — which sighted users read too — or whether it described the toolbar or list the action sits in, and belongs in that node's aria block instead.
    • Done when: No action, top-level or nested under an object, carries aria; the parse refuses it. Every action that had carried an aria.ariaLabel has a label conveying what that name was meant to announce, or the author has moved the text to the placing component's or list view's aria block, or confirmed the existing label already says it. With a screen reader, focusing an icon-only action announces its label.
  • action-block-endpoint-spelling-retired — page.component.action:button.endpoint / page.component.action:icon.endpoint — the endpoint an api action button calls, on the two page blocks that run an action → target — the one key the action runner dispatches an executor on, and the key ActionSchema already renames endpoint to.
    • Why not automatic: The D2 conversion action-block-endpoint-to-target renames endpoint to target in author sources and on every stored-row rehydration, for a block whose actionType is api — the one meaning the key declared, and the rename is lossless there. Three things are left. A block that carries endpoint with no actionType was called through the action runner's legacy API fallback, which a target with no type does not reach, so the author has to add actionType: 'api' as well. A block with another actionType never read endpoint, so only the author can say whether its value should become the target or be deleted. And a block carrying both spellings with different values is left for the author to keep one. Each is left as stored and reported as a TODO. Code is out of reach: a custom action handler that read endpoint off the action it was handed reads nothing once the block carries target.
    • Done when: No action:button or action:icon block carries endpoint in source or at rest; each block that called an endpoint names it as target with actionType: 'api'. Pressing such a button in the console sends one request to that endpoint, and os validate reports no component-props-unknown-key finding for endpoint. No custom action handler reads endpoint from an action dispatched by either block.
  • action-bulk-dispatch-contract-undeclared — ``action.execution — the bulk dispatch contract an action’s body is written for → Declare execution: 'perRecord' | 'aggregate' on every action a list view wires into the selection bar, DERIVED from the wiring that action already has: a view naming it in bulkActions: ['<name>'] (the bare-string form) dispatches it once per selected row with that row's recordId ⇒ execution: 'perRecord'; a bulkActionDefs entry naming it with execution: 'aggregate' dispatches it once for the whole selection with every id in params._selectedIds ⇒ execution: 'aggregate'. The derivation is exact wherever an action is wired ONE way, because the wiring is what the body has been receiving all along — declaring it changes no behaviour, it writes down the behaviour. ⛔ There is no default: an action no view bulk-wires, and an action whose body genuinely serves both contracts (it reads recordId AND _selectedIds and copes with either), stays UNDECLARED rather than being given a value.
    • Why not automatic: Not losslessly convertible, because the fact being written down does not live on the item being rewritten. The declaration belongs to the ACTION and the evidence for it belongs to the VIEWS — potentially several, in other files or other packages — so no per-item transform has both halves in hand, and objectstack migrate meta rewrites stored metadata by key. The residue is genuinely a judgement: an action wired BOTH ways has no correct value, because one call and N calls have different side effects and the platform will not silently unify them (the 2026-09-12 ruling that made an action declare its dispatch contract refused exactly that option). Such an action is TWO actions — split the body along the line the two wirings already draw and declare each half — or, if the body was deliberately written to serve both, it stays undeclared and the two wirings stand. The census that is this migration’s input was taken 2026-09-13 over objectstack@a9c64779046 (shipped app metadata, test fixtures excluded: 13 distinct bulk-wired actions — 11 unambiguously per-record, 1 unambiguously aggregate, 1 wired both ways) and hotcrm@c716a2ccb3d31574a1a238a590f3e331ddae0200 (3 distinct bulk-wired actions — 2 per-record, 1 aggregate, 0 wired both ways). So the both-ways residue is real but rare, which is why it is a structured TODO and not a blocking rewrite.
    • Done when: objectstack validate (and os lint / os build) reports no action-dispatch-contract-mismatch finding on the stack; every action a list view wires into the selection bar either declares the execution its wiring implies, or is deliberately left undeclared with the reason recorded beside it; no action is wired both ways while declaring either contract. Prove the derivation rather than assuming it: for each action you declared 'aggregate', its body reads params._selectedIds and does NOT depend on ctx.recordId; for each you declared 'perRecord', the reverse. Run the bulk button once per declared action against a multi-row selection and confirm the number of dispatches matches the declaration (N for per-record, one for aggregate) — a mismatch that used to be silent is what this key exists to surface.
  • action-engine-facade-find-query-envelope — Action handler body — ctx.engine.find(object, filter) (ActionEngineFacade.find, @objectstack/spec/ui) → ctx.engine.find(object, { where: filter }) — the engine's own query envelope (EngineQueryOptions), the same options bag IDataEngine.find takes. The filter moves under where verbatim: find('task', { status: 'open' }) → find('task', { where: { status: 'open' } }). An unfiltered find(object, {}) is unchanged, and the rest of the envelope — fields, orderBy, limit, offset, expand — becomes reachable from a handler for the first time. A caller-supplied context is ignored: the facade is trusted and stamps its own elevated one.
    • Why not automatic: The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler is authored TypeScript, and the chain rewrites stored metadata by key, so no os migrate meta step can reach a call expression inside a function body. The change is a WITHDRAWAL of the parameter shape an earlier typing fix chose (the filter alone), ruled by the director seat on 2026-09-12, with the maintainer's agreement, on the long-term axis 「one platform, one query shape」. The facade had been given a shape different from the engine's — the where half alone — which made the most natural spelling the wrong one: an author who passed the engine's envelope got { where: { where: … } }, matching no row and resolving to [] with no error, while an unfiltered {} kept working under either belief so a dead handler looked partially alive. The alternative — refusing where at the top level with an intersection — was rejected because it asserts a vocabulary fact the spec declares nowhere, reserving the field name where across every customer's data model to buy one parameter's compile-time check.
    • Done when: Every ctx.engine.find(...) in the app's action handlers passes an envelope. Where the handler is annotated with the PUBLISHED ActionHandlerContext, tsc --noEmit finds every unmigrated call on its own — a bare filter is a compile error there, an object literal failing the excess-property check and a FilterCondition variable failing TS2559. ⚠️ Where it is NOT — a handler in an objectstack.config.js / .mjs, one annotated with a local copy of the context type, or a (ctx: any) handler — the type reaches nothing and a type-check alone proves nothing: those callers are refused at RUNTIME by the facade arm, with the same prescription, so the migration is complete for them only once each such handler has actually been RUN. Then confirm the reads that were already SILENTLY EMPTY: any handler that had been passing the envelope was resolving to [] on every call, so a suite written against the mistake passed and the row count is the only witness — re-run each migrated handler against seeded data and assert it now returns the rows its filter selects, rather than asserting it still resolves.
  • address-location-value-unknown-keys-refused — stored addressandlocation field VALUES (AddressSchema/AddressValueSchema, LocationValueSchema— ADR-0104 D1), and the two authoring doors that parse the same contract: alocation/addressfield's literaldefaultValue and an action param of those types — undeclared keys → the declared key the rejection names. An address value accepts exactly street, city, state, postalCode, country, countryCode, formatted; a location value exactly lat, lng, altitude, accuracy. Every rejection carries the surface, the offending key and a rename (postal_code / zipCode / zip / postcode → postalCode, latitude → lat, longitude → lng). A key that names no declared member is removed at the producer — never tolerated at a consumer: an alias for an off-spec key in a consumer stays forbidden (contract-first — fix the metadata, not the runtime)
    • Why not automatic: Maintainer ruling 2026-09-01, option A: both value classes refuse undeclared keys. Both value classes were all-optional STRIPPING z.objects, so a value with a completely wrong key set parsed green and the wrong keys vanished from the parse output: the showcase seed wrote postal_code, the platform accepted it, dropped it, and rendered an empty ZIP box (found while counting stored address values for objectui's survey of which structured values its field validator checks; an earlier report had named the same stripping on the address widget's round-trip, whose ZIP input bound zipCode against a stored postalCode), while a stored-value scan over the class could only ever report a clean count it had no way to earn. Closing the two shapes restores declared = enforced and pulls "loose" back to the one deliberate exception (FileValueSchema, untouched). Where the refusal BITES is the ADR-0104 write path's own evidence-gated posture, deliberately unchanged: a record write carrying an undeclared key is refused only on a deployment that has attested adr-0104-value-shapes (or opted in with OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1); everywhere else it stays warn-first and is reported to the admitted-violation sink, and os migrate value-shapes now COUNTS such keys, so a deployment holding them cannot attest until they are cleaned. No read path parses these shapes; a stored value reads back as written.
    • Done when: os migrate value-shapes reports zero findings on address / location fields — every stored value carries only declared keys (postalCode, never postal_code / zipCode; lat / lng, never latitude / longitude / heading / speed) — and every address / location defaultValue literal and action-param value parses with only declared keys. Declared keys parse byte-identically to before; FileValueSchema still admits extra keys.
  • admin-export-wildcard-removed — the SHIPPED platform admin permission sets admin_full_access, organization_adminand the derivedorganization_admin_no_bypass— theirobjects["*"].allowExport = true wildcard grant (REMOVED; the rest of the wildcard is unchanged) → an explicit allowExport: true on the object entries of an APP-authored permission set held by the principals meant to keep exporting. Nothing replaces the grant in the platform sets themselves
    • Why not automatic: A capability NARROWING of a published set, and — like export-axis-opt-in, whose 17.0 story this completes — one no gate can announce: the metadata is unchanged and still parses, the shipped sets are re-seeded on upgrade, and the only observable is that an export which returned 200 now returns 403 EXPORT_NOT_PERMITTED. export-axis-opt-in told upgraders that "package-shipped sets are re-seeded on upgrade, so the built-ins are handled — admin_full_access and organization_admin now carry the grant explicitly"; from this major they deliberately do NOT, so a deployment that read that sentence and left its admins to the built-ins must now act. What the wildcard did, measured on 17.0.0 GA across 40 export probes: an org owner exported three objects on which NO app permission set granted export, 200 with full rows, and the app had no way to refuse — editing a code-package set answers 403 [not_overridable], and the org admin holds no app-authored set in which to write the per-object false that would have won. So an application could declare an object exportable by nobody, ship, and be silently wrong on an exfiltration boundary — declared ≠ enforced, on the axis where a silent gap costs the most. This is the 2026-08-07 ruling on the member baseline applied to export: that change removed member_default's CRUD wildcard because a wildcard in a set every principal resolves is not a default but a floor nobody can get under; the export wildcard survived by omission rather than by decision, one tier up. It cannot be mechanically converted, in either direction: re-granting allowExport wherever an admin holds a set would restore today's behaviour and defeat the entire point, and leaving it withheld may revoke export an operator legitimately wants. WHICH principals may take a bulk copy is the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Note the boundary this does NOT move: the export gate itself is unchanged and was never the defect (controls C1–C3 of the same run show it enforcing exactly), specific-over-wildcard precedence is unchanged, allowExport on a "*" entry remains a supported authoring shape in an app's OWN sets, and READ is untouched — an admin still sees every record they saw before. ADR-0087; maintainer ruling 2026-08-15, which removed allowExport from the wildcard entry of both shipped admin sets.
    • Done when: For every principal whose ADMIN export you rely on, the grant is now authored where you control it: an app/environment permission set held by that principal names each object they must export and carries allowExport: true on it. Verify BEHAVIOURALLY — nothing fails at parse time, and a re-seed silently replaces the old built-ins: sign in as an org owner or platform admin and call GET /api/v1/data/<object>/export, confirming 200 where export is intended and 403 EXPORT_NOT_PERMITTED where it is not. ⚠️ Silence is not success: a deployment that upgrades without editing anything is VALID metadata whose administrators have quietly lost export on every object no app set grants, and the first sign will be a support report rather than an error. The reverse reading is worth one pass too — an object your app declares exportable by nobody is now genuinely exportable by nobody, which is the point of the change; confirm that is what you want before granting it back.
  • admin-scope-business-unit-blank-refused — security.permission.adminScope.businessUnit (AdminScopeSchema, ADR-0090 D12) authored or stored BLANK: the empty string, or a value that is nothing but whitespace (spaces, a tab, a newline). The key is the scope's only required one and names the root business unit of the delegated subtree; a blank value satisfied the requirement while naming no unit → the sys_business_unit.name (machine name) of the business unit at the root of the subtree the delegate administers, written out: businessUnit: 'north_america'. If the permission set should not delegate administration at all, remove adminScope from it. ⛔ There is no replacement that can be DERIVED from what was written: a blank names no unit, so the root the author meant is not recoverable, and the platform must not pick one.
    • Why not automatic: Maintainer ruling A, 2026-09-23: an empty or whitespace-only businessUnit is refused at parse, and stored scopes are not rewritten. AdminScopeSchema declared businessUnit as a bare string with no minimum, so { businessUnit: '' } and { businessUnit: ' ' } parsed green — measured against the published spec 17.4.0 and re-measured on main before the change. This narrows a published face: every other key of the scope is scoped TO this one, and ADR-0090 D12 declares the scope's WHERE as a business-unit subtree, which a blank does not name. The delegated-admin gate resolves the anchor by exact name, so a blank anchor resolves to an empty subtree and approves nothing on the subtree axes — no escalation was measured; the defect is a declaration that does not enforce what it declares, satisfied most readily by an author (an AI author above all) that knew the key was required and did not yet know the unit. The refusal is a NON-TRANSFORMING refinement at the key's own path, deliberately not a trim: the metadata save path persists the submitted body verbatim rather than the parsed value, so a trimming schema would validate one string and store another that the gate's exact lookup cannot resolve. A real name therefore parses byte-identical. Scope is blankness only: a real name with surrounding whitespace is not judged by this entry. ⚠️ STORED ROWS ARE NOT REWRITTEN and there is no D2 conversion (no lossless rewrite exists — the root cannot be inferred, and dropping the scope would silently change who is a delegate). The read path does not re-validate stored rows, so no stored permission set becomes unreadable. A stored blank-anchored scope is refused on its NEXT WRITE instead: a Setup or data-door edit of that permission set answers 422 INVALID_METADATA naming adminScope.businessUnit, and the boot reconciliation backfill of a legacy record with no metadata definition reports it through its existing durability ERROR (ADR-0094 D4), whose own prescription is to make the record body spec-valid; restoring a trashed blank-anchored set brings the record back and reports the missing definition at ERROR the same way. ⛔ No path skips the row. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0090.
    • Done when: Search every authored and stored permission set for an adminScope whose businessUnit is empty or whitespace-only — metadata files, sys_metadata permission rows, and the admin_scope column of sys_permission_set — and write the root unit's machine name, or remove adminScope where the set should not delegate. The sweep is mechanical for authored metadata: AdminScopeSchema.safeParse and PermissionSetSchema.safeParse answer exactly one custom issue at businessUnit (or adminScope.businessUnit when the set is parsed whole) naming sys_business_unit.name as the valid anchor. For STORED rows the search above is the catch-all, because reads never refuse: a definition already stored in sys_metadata with a blank anchor loads and resolves exactly as before and says nothing until it is written again. Two write paths then name it: a re-save of the set (refused 422 at adminScope.businessUnit), and — for a legacy sys_permission_set record with no metadata definition only — the boot log, where the ADR-0094 D4 backfill's first-failure ERROR names the record and carries the offending key and its summary ERROR counts and names the failed records. ⛔ A clean boot is therefore NOT a completed sweep. An absent businessUnit is refused exactly as before, with its own invalid_type issue. Nothing is normalised on the way through: an accepted anchor arrives byte-identical to what was written. The repo and example apps carried zero blank anchors at the time of the change, so no fixture had to be rewritten.
  • advanced-plugin-lifecycle-config-retired — kernel.advancedPluginLifecycle (the authorable config surface of plugin-lifecycle-advanced.zod.ts— 3 defs, 9 exported names:AdvancedPluginLifecycleConfigSchema/AdvancedPluginLifecycleConfig/AdvancedPluginLifecycleConfigParsed, GracefulDegradationSchema/GracefulDegradation/GracefulDegradationParsed, PluginUpdateStrategySchema/PluginUpdateStrategy/PluginUpdateStrategyParsed) → (removed — there is no declarative replacement, because nothing ever read the declaration. The supported lifecycle surface is the HOST-DRIVEN library in @objectstack/core: construct PluginHealthMonitor and pass a PluginHealthCheck per plugin, construct HotReloadManager and pass a HotReloadConfig — the content/docs/protocol/kernel/lifecycle.mdx examples — rewritten to show the plugin exposing a method and the host registering it, never a declarative field — are the supported usage, and those input vocabularies (PluginHealthStatus / PluginHealthCheck / PluginHealthReport, HotReloadConfig with its embedded DistributedStateConfig, PluginStateSnapshot) SURVIVE in the same module as library parameter types. Degradation and update-strategy vocabularies return only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, route 2: retire the config container and keep the classes as a host-driven library. The container aggregated six config groups — health, hotReload, degradation, updates, resources, observability — and NO group had a runtime reader, re-measured per group at the retirement's base commit (8cdd696) with positive controls: the kernel never constructs PluginHealthMonitor or HotReloadManager (only their own unit tests and core/examples/phase2-integration.ts do, passing config DIRECTLY to the classes, never through this container); degradation / updates / resources / observability keys have no implementation body at all (controls: checkMethod resolves to core/src/health-monitor.ts and debounceDelay to core/src/hot-reload.ts, proving the scan sees real readers; the bare-name collisions — plugin-ordering's optionalDependencies, auth-manager's private degradedFeatures, plugin-security-advanced's resourceLimits.maxCpu read by sandbox-runtime.ts — are different surfaces, verified structurally). No manifest, stack collection or metadata-type binding ever embedded the container, so no authored document could carry it: an author declaring health: {...} or rollback: { automatic: true } got a clean parse and NOTHING — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, at container scale, sharpened by production-safety vocabulary (auto-restart, zero-downtime rolling updates, automatic rollback) an AI author (ADR-0033) reads as proof the capability exists. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the dynamic plugin-loading family's removal and of the retired ApiKeySchema — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration.
    • Done when: No code imports any of the 9 retired names from @objectstack/spec or @objectstack/spec/kernel — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in kernel/plugin-lifecycle-advanced-retirement.test.ts). No metadata document needs editing: the container was reachable from no metadata-type binding, stack collection or manifest embed, so no document could ever carry it. The host-driven library vocabularies survive unchanged on ./kernel (PluginHealthStatusSchema, PluginHealthCheckSchema, PluginHealthReportSchema, HotReloadConfigSchema, DistributedStateConfigSchema, PluginStateSnapshotSchema — same pin), and PluginHealthMonitor / HotReloadManager stay exported from @objectstack/core with their tests green. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the container, so removing it removes no behaviour.
  • agent-lifecycle-retired — agent.lifecycle — the agent conversation state machine left the shape; with it the XState StateMachineSchema family left @objectstack/spec/automation (StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema and their types), and StateNodeConfig left the root and /ai entries → no key: delete lifecycle from every agent. Put what the machine meant where the platform enforces it — a phase of a conversation is a skill with its own instructions and tools, selected by its triggerConditions and attached through the agent's skills; a multi-step process is a Flow; a record's status transitions are a state_machine validation rule on the object (a flat table of each state's allowed next states). Code that imported the state machine exports declares the shape it needs itself, or drops it
    • Why not automatic: ADR-0049 enforce-or-remove: agent.lifecycle was parsed and never read. No runtime — not this repository, not the cloud AI runtime that executes agents — moved an agent through a declared state or refused an undeclared transition, so an authored machine changed nothing an agent did. Enforcing it would have meant a statechart interpreter beside Flow, the two-engine shape ADR-0020 rejected, and what it reached for is already served: conversation phases by skills (ADR-0064), orchestration by Flow (ADR-0019), record transitions by the state_machine validation rule (ADR-0020). Authoring now refuses the key with that prescription, and TypeScript rejects it. The D2 conversion agent-lifecycle-removed deletes it from existing sources and stored agent rows, losslessly. StateMachineSchema had kept its file only for this door (ADR-0020 implementation note 1), so the family left with it — which of the three destinations each deleted machine meant is the author's judgement, not a mechanical rewrite
    • Done when: No agent declares lifecycle; it is refused at parse with its prescription, and TypeScript rejects it. Every conversation phase a deleted machine described is a skill the agent lists in skills, with its own instructions, tools and triggerConditions; every multi-step process it described is a Flow; every record status transition it described is a state_machine validation rule on that object. No source imports StateMachineSchema, StateNodeSchema, TransitionSchema, ActionRefSchema, GuardRefSchema or their types from @objectstack/spec. Every agent parses under the new schema.
  • agent-memory-store-retired-and-limits-required — agent.memory — longTerm.store left the shape (the memory store is the platform's); longTerm.maxEntries and reflectionInterval are required when longTerm.enabled is true, and reflectionInterval is refused without an enabled longTerm; longTerm.enabled is unchanged → no storage key: delete longTerm.store, whatever it held — where long-term memory notes are kept is the platform's choice. An agent whose longTerm.enabled is true declares longTerm.maxEntries (how many distilled notes are kept for each user; the newest are recalled before the first round and older ones evicted) and memory.reflectionInterval (how many delivered interactions pass between the reflections that write a note). An agent without enabled long-term memory declares no reflectionInterval
    • Why not automatic: ADR-0049 enforce-or-remove: the agent.memory contract states exactly what the runtime honours. The cloud AI runtime, the one runtime that executes agents, enforces long-term memory from enabled, maxEntries and reflectionInterval: it recalls the newest maxEntries notes before the first round, writes one note every reflectionInterval delivered interactions, and evicts notes beyond maxEntries. It keeps the notes in its own database store, and before an agent's first turn it refused the vector store (the old default, so what an omitted store parsed to), redis, an enabled longTerm missing either number, and a reflectionInterval without an enabled longTerm. Authoring now refuses the same declarations, each with a prescription. The D2 conversion agent-memory-long-term-store-removed deletes store from existing sources and stored rows, losslessly: no value of it ever chose a backend. No default is declared for either number, because none has a measured basis — so an agent with long-term memory enabled and either number missing no longer parses, and only its author can choose the numbers it needs
    • Done when: No agent declares memory.longTerm.store, or a backend, storage or provider key under longTerm; each is refused at parse with its prescription, and TypeScript rejects store. Every agent whose longTerm.enabled is true declares both longTerm.maxEntries and memory.reflectionInterval, each an integer of at least 1 chosen for that agent, and no agent declares reflectionInterval without an enabled longTerm. Every agent parses under the new schema.
  • agent-structured-output-refused-members-retired — agent.structuredOutput — the values 'regex', 'grammar' and 'xml' left StructuredOutputFormat (at format and fallbackFormat) and 'coerce_types' left TransformPipelineStep (at transformPipeline); 'json_object', 'json_schema', 'trim', 'parse_json' and 'validate' are unchanged → a JSON contract: format: json_schema with a JSON Schema in schema when the answer must have a shape, or format: json_object when any JSON value will do — or no structuredOutput block at all when the agent needs no output contract. A fallback format names one of the two JSON formats or is left out. In place of coerce_types, declare the exact types in schema, so the answer is validated as the model wrote it
    • Why not automatic: ADR-0049 enforce-or-remove. The cloud AI runtime, the one runtime that executes agents, enforces structuredOutput on every final answer and refuses an agent that declares any of these four members before its first turn: the spec never had a key to carry the pattern or grammar a regex or grammar answer would be checked against, a final answer is checked only as JSON, and no coercion engine exists. Hosted model APIs constrain a final answer by JSON Schema only; regex and grammar constraints live in inference engines and in tool-input formats, not on an agent's answer. The D2 conversion agent-structured-output-refused-members-removed makes each stored or existing source parse: it DELETES a block whose format was retired, deletes a retired fallbackFormat, and drops coerce_types from the pipeline. The deletion of a block is the edit that needs judgement: it removes an output contract the runtime never kept, and only the author can say whether the agent should now carry a json_schema contract instead — the conversion cannot write the schema the author meant
    • Done when: No agent declares format or fallbackFormat as regex, grammar or xml, and no transformPipeline lists coerce_types; each is refused at parse with its prescription, and TypeScript rejects each at a StructuredOutputFormat or TransformPipelineStep position. Every agent whose block the conversion deleted either carries a json_schema contract whose schema states the answer it must give, or the author has confirmed it needs no output contract. An agent that relied on coercion declares the exact types in schema and a test turn returns an answer that validates without conversion.
  • ai-conversation-analytics-duration-unit-in-key — ConversationAnalytics.duration, the emitted session length whose name carried no unit (ai/conversation.zod.ts) → durationSeconds — rename the key; the value is unchanged
    • Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender in ai/ and the only one on its file. What makes the bare name worth a registry row rather than a quiet edit is the company it kept: every other number on ConversationAnalytics is a COUNT — totalMessages, totalTokens, peakTokenUsage, pruningEvents, tokensSavedByPruning — so the one field that carried a unit was the one field that did not say so, sitting in a block of twelve unitless integers. The two instants beside it, firstMessageAt and lastMessageAt, already spelled themselves; the measurement between them did not. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an emitter writing the old spelling would lose the value with no error anywhere. Why a semantic entry and not a D2 conversion: conversation analytics are computed at runtime and handed to a consumer, never authored by hand and never stored as a sys_metadata row, so the conversion chain has no seam that would ever see one — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087.
    • Done when: Every producer that BUILDS a ConversationAnalytics spells durationSeconds, and every consumer that reads a session length reads durationSeconds. Authoring duration fails to compile (input type never) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: durationSeconds: 1800 is the same half hour duration: 1800 was, the key stays optional, and the non-negative bound rides along with the renamed key so a negative session length is still refused. The migration is proved correct when no source in the tree spells a bare duration on this shape AND the twelve sibling counts are untouched — a sweep that suffixed any of them has read a count as a duration and over-applied the rule.
  • ai-json-schema-untyped-subschema-refused — action.ai.outputSchema (stack actions and object-nested actions) and agent.structuredOutput.schema — a JSON Schema in which an object subschema with no type carries a type-scoped keyword → the same schema with a "type" declared on every subschema that carries a type-scoped keyword: "object" beside properties, required, additionalProperties, patternProperties, propertyNames, minProperties or maxProperties; "array" beside items, prefixItems, contains, minItems, maxItems or uniqueItems; "string" beside minLength, maxLength, pattern or format; "number" or "integer" beside minimum, maximum, exclusiveMinimum, exclusiveMaximum or multipleOf. A subschema meant to accept several types declares them as an array ("type": ["string", "null"]).
    • Why not automatic: Both slots are compiled by the cloud AI runtime — action.ai.outputSchema before the action runs, to validate its result, and agent.structuredOutput.schema as the agent's structured-output contract — and both readers call one guard whose schema reader does not check a type-scoped keyword on a subschema that declares no type. That guard refuses the whole schema before anything runs. The spec declared both slots as open records, so such a schema passed defineStack, objectstack validate and the metadata save door, and the author learned of it only when the action or agent was invoked. Both slots are now one declaration that mirrors the guard exactly — the same 22 type-scoped keywords (properties, required, additionalProperties, patternProperties, propertyNames, minProperties, maxProperties, items, prefixItems, contains, minItems, maxItems, uniqueItems, minLength, maxLength, pattern, format, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf), present with any value on an object node whose type is absent; the same descent into every value of properties, patternProperties, $defs, definitions and dependentSchemas and into the single subschema or each array entry of items, additionalProperties, contains, propertyNames, not, if, then, else, unevaluatedProperties, unevaluatedItems, anyOf, oneOf, allOf and prefixItems, under typed and untyped parents alike, without following $ref — and refuses each offending subschema at its own path with the type to declare named. Boolean subschemas, {}, a node with any type value, and an untyped node carrying only keywords outside the list (enum, const, $ref, anyOf, title, …) are accepted, as the runtime accepts them. Measured on the built package: the per-type schema the metadata save door validates with refuses an action, an object-nested action or an agent carrying such a schema at the subschema path, and defineStack throws with the same path; the same schema with type declared is accepted at both. Read from source and not run: a row already stored still loads, because the database loader replays the conversion chain and parses nothing, and its next save is refused until the type is declared. No conversion is registered: every refused schema was already refused by the runtime, so nothing that worked stops working; and supplying a type is a judgment about what the author meant, not a lossless rewrite, because declaring one also narrows what the schema accepts. Population measured at the change, on origin/main 135daaa06b: zero untyped subschemas in the three authorings of either slot across the package fixtures (one action ai.outputSchema, two structuredOutput.schema), and zero authorings of either slot in the examples, the documentation and the published skills; the one outputSchema the examples carry is a connector action's, a different key. Deployed metadata NOT MEASURED.
    • Done when: Every ai.outputSchema on an action (stack-level and object-nested) and every structuredOutput.schema on an agent parses: objectstack validate reports no issue whose message reads uses "…" without a "type" at either slot, and every subschema in either schema that carries a type-scoped keyword declares its type. The action or agent then runs past the AI runtime's schema compilation instead of being refused before it runs.
  • analytics-authorable-unknown-keys-refused — analytics cube definitions (defineCube/defineStack({ analyticsCubes }): the cube, each metric, each dimension, each join) and the /analytics/querybody's nestedtimeDimensions[] items — undeclared keys → the declared key the rejection names. Every rejection carries the surface, the offending key and a rename suggestion (title → label on a metric/dimension, label → title on the cube, granuarity → granularity, orderBy → order; filters on a query gets the where prescription). A key that names no supported capability is simply removed
    • Why not automatic: The unknown-key strictness campaign (the sweep that ended silent stripping of undeclared keys as the default, one schema family at a time), its data/ batch. These shapes parsed .strip — an undeclared key on an authored cube was silently dropped, so a join authored with a typo'd relationship registered with the many_to_one default (a different join than the author declared) and a metric's misspelled key vanished under a successful parse. The subtle half: /analytics/query's top level has been strict since the degraded shim's envelope dialect was retired (one URL, one request body), but top-level strictness does not recurse — timeDimensions: [{ dimension, granuarity: 'day' }] rode through the strict wrapper with the typo stripped, bucketing the whole range as one group under an ordinary 200. Undeclared keys on all eight sites are now refused at parse time with a prescriptive message. (Two of the eight were themselves removed later in this major, because nothing ever read them: the nested metric filters[] item, by metric-filters-removed, and the cube's refreshKey block, by cube-refresh-key-removed.)
    • Done when: Every cube in defineStack({ analyticsCubes }) / defineCube parses with only declared keys at every level (cube, measures, dimensions, joins); every /analytics/query body's timeDimensions[] items carry only dimension/granularity/dateRange. Declared keys parse byte-identically to before.
  • analytics-cube-public-default-visible-enforced — data.Cube.public — an analytics cube that declares public: false, and every cube in an artifact built by os compile before this release (the compiler writes the parsed stack, so it carries a materialized public: false on each cube that omitted the key) → nothing, to keep a cube queryable: cubes are visible by default. Delete an authored public: false that only restated the old default, and write it only on a cube that must stay out of the analytics API. Recompile every os compile artifact built before this release
    • Why not automatic: A DECLARED-DEFAULT CORRECTION plus the enforcement that makes the key real. The analytics cube schema declared public with a default of false under an access-control comment, and nothing read it: /analytics/meta listed every cube and every query door answered it. The analytics service now reads it. A cube declared public: false is left out of /analytics/meta, and /analytics/query and /analytics/sql refuse it with 404 CUBE_NOT_FOUND — the same refusal, byte for byte, that an unknown cube name gets, so the refusal does not confirm a hidden cube exists. Enforcing the old default as declared would have hidden every cube that omits the key, so the default moves to true in the same change, and a cube that omits the key stays visible exactly as it was. Two holdings change behaviour on upgrade. An authored public: false — including one copied from the example app, which carried it — now hides the cube and refuses its queries. And an artifact built by os compile before this release carries a materialized public: false on every cube that omitted the key, because the compiler writes the parsed stack with its defaults applied; a host that registers cubes from such an artifact hides all of them until the artifact is recompiled. The key is visibility, not row security: records stay governed by object permissions and row-level security on every door, and the metadata door keeps serving cube definitions.
    • Done when: GET /analytics/meta lists every cube your dashboards and reports query, and a query naming each one answers 200. For each authored public: false, either delete it (the cube is meant to be queried) or keep it and confirm that /analytics/meta omits the cube and that a query naming it answers 404 CUBE_NOT_FOUND. Every compiled artifact in use was built by os compile from this release or later.
  • analytics-cube-single-granularity-default-enforced — data.Cube.dimensions.granularities — an analytics cube time dimension whose granularities list holds exactly one interval, whether an author wrote it that way or the protocol 18 conversion cube-sub-day-granularities-removed reduced a longer list to it → nothing, when that one interval is the bucket the dimension should be grouped at by default. When it is not, list every interval the dimension serves (two or more state no default) or omit the key. A dashboard or report whose query the engine aggregate path cannot evaluate (a custom-SQL measure, or a member of a cube with joins that resolves through one) either stops grouping by such a dimension or groups by one that declares no single interval
    • Why not automatic: An inert key made real. A cube time dimension's granularities was read only for a cube the dataset compiler minted, where a one-interval list is the dataset's default bucket. A cube authored with defineCube() or defineStack({ analyticsCubes }) never reached that reader, so grouping by its time dimension grouped raw timestamps, one group per distinct instant, whatever the list said. The analytics service now reads every cube by the compiled-dataset rule: on /analytics/query and on the /analytics/sql dry run, a time dimension the query groups by without stating a granularity is bucketed at the one interval its list declares. A granularity the query states still wins, one the list does not name is not refused, and a list of two or more states no default. Two holdings change on upgrade. A query grouping by such a dimension answers one row per bucket where it answered one row per timestamp. And a bucketed query leaves the raw-SQL path, which declines every bucketed query, for the engine aggregate path, which answers 400 INVALID_FIELD for every member it cannot evaluate — the same refusal, byte for byte, that the same query already got with that granularity stated by hand. Those members are: a custom-SQL measure (a number, string or boolean measure whose sql is an expression); and, on a cube whose members resolve through its joins, a measure or a where field over a joined object, a timeDimensions entry over a joined object (bucketed or a window, so grouping by a one-interval time dimension over a joined object is refused too), a dimension that traverses more than one relationship, and an avg or count_distinct measure beside any dimension over a joined object. The raw-SQL path serves every one of these, so each such query grouped by such a dimension goes from answered to refused. On a host whose queryCapabilities offers raw SQL with no engine aggregate bridge (a hand override: the analytics plugin wires both), no strategy remains for a bucketed query, so every newly bucketed query, a plain count included, goes from answered to "No strategy can handle query". The protocol-18 conversion cube-sub-day-granularities-removed strips the retired sub-day intervals from every authored and stored cube, so a dimension that offered one sub-day interval and one coarser interval now holds a one-interval list: a default bucket its author never wrote.
    • Done when: Every cube time dimension whose granularities lists exactly one interval is one you mean to bucket at that interval by default: a query that groups by it on /analytics/query answers one row per bucket, and /analytics/sql shows the bucketed statement. Every time dimension that should have no default lists two or more intervals or omits the key. No dashboard or report groups by a one-interval dimension a query the engine aggregate path refuses — a custom-SQL measure; a measure, where field or timeDimensions entry over a joined object; a dimension that traverses more than one relationship; an avg or count_distinct measure beside a dimension over a joined object — or each one that did now groups by a dimension without a single interval. A host that overrides queryCapabilities to raw SQL only either adds an engine aggregate bridge or groups by no one-interval dimension.
  • analytics-date-range-array-two-bounds-required — the ARRAY arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored with anything other than EXACTLY two string bounds: a one-element window such as ["2026-01-01"], the empty array [], and three or more bounds such as ["2026-01-01", "2026-01-31", "2026-02-28"] → exactly two string bounds — [start, end]. A ONE-ELEMENT window is that day written as BOTH bounds: ['2026-01-01'] becomes ['2026-01-01', '2026-01-01'], the shape the shipped migration table for the closed preset vocabulary already prescribes for a single day, and the shape all four analytics faces have selected that one day with since the fix that made them read the array arm one way. ⛔ The EMPTY array and THREE-OR-MORE bounds have NO replacement that can be derived from what was written: an empty array names no window at all, and a 3+ array names no pair — decide the window the widget was meant to show and write its two bounds, or drop the dateRange entirely (the field is optional, and absent means the query is not time-bounded). A relative window is a preset name from the closed vocabulary ('last_7_days') or a date-macro pair (['{7_days_ago}', '{today}']).
    • Why not automatic: Maintainer ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the array arm to exactly two string bounds: the arm was a bare z.array(z.string()) with NO length constraint, while the refusal sentence in the same source file said verbatim that "an explicit window is the two-element array [start, end]" and the shipped migration table for the closed preset vocabulary told an author to write a single day as ['2026-01-20', '2026-01-20']. So only the TYPE was weaker than the prose beside it, and a measurement of one authored document on each face found what that bought: one authored ['2026-01-01'] meant a point window on ObjectQLStrategy, NO time clause at all on NativeSQLStrategy (the whole of history), an unbounded-above window in the draft-preview evaluator, and a shifted point window in DatasetExecutor.runCompare — the same document, four backends, four different numbers, no error on any of them. The fix that followed made all four faces refuse it with the ADR-0112 envelope 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED, which left the contract door LOOSER than every reader behind it; this narrowing closes that gap at the door. ⚠️ No D2 conversion and no stored-metadata rewrite, deliberately: rewriting ['2026-01-01'] to the same day twice at load would be the platform deciding, silently, that the author meant one day rather than a window whose end they forgot — and for the empty array and 3+ bounds there is nothing to decide FROM. The blast radius is the WIDGET, not the page: a stored dashboard carrying a now-refused range loses that widget with the accurate refusal shown, and the dashboard still loads. Since that fix every such stored range already failed at QUERY time with the same code and status, so this adds no new class of breakage — it moves the refusal to authoring time and states it accurately. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep every authored timeDimensions[].dateRange ARRAY — dashboard widget datasets, saved analytics queries, SDK / MCP callers, in-process AnalyticsService.query calls — and count its bounds. Two string bounds parse byte-identically to before, as do every preset name and an absent dateRange; anything else now answers one prescriptive issue at timeDimensions.N.dateRange naming the arity it received, so AnalyticsQuerySchema.safeParse and POST /analytics/query both make the sweep mechanical. ⚠️ Do not trust the numbers a one-element window used to produce: the four analytics faces disagreed about what it meant, so a widget that showed a plausible figure may have been reading all of history on one backend and a single day on another. Re-check what each converted widget was supposed to show against its two explicit bounds.
  • analytics-query-window-non-negative-integer — the row window of an analytics query — limit and offset on AnalyticsQuerySchema, the POST /analytics/query and /analytics/sql bodies, and a dataset selection (POST /analytics/dataset/query) — authored as a negative number (limit: -1, offset: -1), a fraction (limit: 1.5), or an integer above Number.MAX_SAFE_INTEGER → a non-negative integer, or no key at all: delete limit to return every row (a limit: -1 written to mean "no limit" is exactly that), write limit: 0 only for no rows, and delete offset (or write offset: 0) to skip nothing; a fraction becomes the integer page size that was meant. An offset with no limit stays valid and returns every row after the offset, on SQLite and PostgreSQL alike
    • Why not automatic: Both members were a bare z.number(), and every value outside the non-negative integers answered differently per driver and per face. Measured at POST /api/v1/analytics/query on SQLite and PostgreSQL 16, through the real dispatcher route: limit: -1 returned every row on the native SQLite face, a 500 on native PostgreSQL and all but the last row on the ObjectQL face; limit: 1.5 answered a 500, two rows and one row; offset: -1 a 500 on both native drivers and every row on the ObjectQL face. No value had one answer, so the contract refuses them instead of any engine guessing (contract first, ADR-0049): the two members are z.number().int().nonnegative() on AnalyticsQuerySchema, the dataset selection reads the same two declarations off its shape, and the runtime doors answer the ADR-0112 envelope 400 VALIDATION_FAILED naming limit or offset before any engine runs. ⚠️ No D2 conversion and no stored-metadata rewrite: the window is a QUERY-time request field, not a sys_metadata shape, and the refused values meant different windows on different backends, so coercing one would be the platform guessing which the author meant. The one stored producer that lowers into a selection, a dashboard widget's limit, is already declared a positive integer. Measured in this repository at the change: no example, fixture, document or published skill authors a negative or fractional analytics window. ADR-0049 / ADR-0112.
    • Done when: Grep every authored analytics limit and offset — saved analytics queries, SDK and MCP callers, dataset selections, and queries a host builds in-process — and rewrite each negative or fractional value as described. POST /analytics/query and /analytics/sql answer 400 VALIDATION_FAILED with details.fields[].field naming limit or offset, and POST /analytics/dataset/query names selection.limit or selection.offset, so a sweep is mechanical; AnalyticsQuerySchema.safeParse reports the same issue at the member. Non-negative integer windows parse byte-identically to before, limit: 0 still answers no rows, and absence stays absence. The service does not parse a query passed to it in-process, so a host that builds one parses it with AnalyticsQuerySchema first. A query that carried a refused value was never returning one window, so re-check what the widget was meant to show rather than trusting the old result set.
  • analytics-row-wildcard-outside-count-refused — analyticsCubes[].measures.<metric>.sql, analyticsCubes[].dimensions.<dimension>.sql and datasets[].measures[].field (data.MetricSchema.sql / data.DimensionSchema.sql / ui.DatasetMeasureSchema.field) authored as the row wildcard * where no count consumes it — a cube measure whose type is anything but count, any cube dimension, and a dataset measure whose aggregate is anything but count or that declares none (a derived measure) → what the member meant. A row count: type: 'count' on a cube measure or aggregate: 'count' on a dataset measure, keeping '*' (a dataset count may also omit field). An aggregate of values: the column it aggregates — a field of the object (amount) or a relationship path ending in one (account.amount). A cube dimension: the column it groups by; to count rows, declare a count measure instead. A derived measure: delete the field key, which nothing read — a derived measure combines other measures by name
    • Why not automatic: '*' is the row wildcard a count aggregates (COUNT(*)): it reads no field value, so no other aggregate has a column to read over it, and a dimension has no aggregate at all. The contract nevertheless admitted it in a cube member's sql on any measure and on a dimension, and in a dataset measure's field under any aggregate, and the analytics strategies passed it to the database as written. Measured at POST /api/v1/analytics/dataset/query over a real SQLite driver, on the native-SQL and the ObjectQL strategy alike: a dataset measure aggregating '*' under sum, avg, min, max or count_distinct answered 500 DATABASE_ERROR — a server fault for an authoring mistake the contract had admitted. A dataset measure compiles to the cube measure it names verbatim, so the same reading covers an authored cube measure; a dimension over '*' (GROUP BY *) was measured the same way when the dataset dimension was narrowed. Such a member never produced an answer, so no working document changes meaning: the failure moves from the query to the authoring parse, which names the slot and the aggregate and prescribes a count or a column. There is no D2 conversion: rewriting to count would change the figure the author asked for, and only the author knows which column a sum over '*' was meant to read. A STORED document is not rewritten: a metadata read still serves it as stored, with the refusal on its read diagnostics, and a re-save through the metadata write door is refused at the slot. The dataset query door parses every dataset it is handed, inline or saved, so a stored dataset carrying such a measure is refused 400 VALIDATION_FAILED on EVERY query — including a query that selects only its other measures, which used to answer: it fails closed until the member is fixed. An authored cube reaches the analytics runtime through the stack definition, whose parse refuses it when the stack is built. In-repo census before the change: no example, platform object, doc, skill or fixture authored one, and neither did objectui at the pinned commit; deployed metadata was NOT measured. ADR-0021 / ADR-0049 / ADR-0087
    • Done when: Every analytics cube and dataset parses: CubeSchema, DatasetSchema, the analytics_cube and dataset write doors, defineCube and defineStack refuse '*' on a cube measure whose type is not count and on a dataset measure whose aggregate is not count (at its sql / field, code custom), and on a cube dimension (at its sql, code invalid_format), each naming the slot and prescribing a count or a column, so the sweep is mechanical — parse each document, and each refusal is one member to change. For each changed member, a query that selects it returns a figure instead of a 500, and a dashboard bound to a stored dataset that carried one answers again on every widget. A count over '*', a dataset count with no field, and every member that names a column parse byte-identically to before and run on both strategies.
  • analytics-time-dimension-date-range-vocabulary-closed — the bare-STRING arm of timeDimensions[].dateRange on an analytics query — AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset selection's timeDimensions, and any AnalyticsQuery a host passes to AnalyticsService.query in-process — authored as anything other than one of the thirteen declared date-range preset names (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days): the display spelling "Last 7 days" the schema comment used to show, the driver-memory dialect "last N days" / "last 3 months", or a bare ISO date such as "2026-01-20" (the SQL strategies' single-day dialect) → a preset name from the closed vocabulary — 'last_7_days' for "Last 7 days" / "last 7 days", 'last_30_days', 'this_month', and so on (DATE_RANGE_PRESETS in @objectstack/spec/data is the list; the rejection prints it) — or, for an explicit window, the two-element array the array arm always accepted: ['2026-01-20', '2026-01-20'] for the single day a bare ISO string used to mean on SQL, ['2026-01-01', '2026-01-31'], or ['{7_days_ago}', '{today}'] in date-macro tokens
    • Why not automatic: Maintainer ruling of 2026-09-06 on the analytics date-range string (option A — contract first): the protocol is the baseline, so the vocabulary is declared once in the schema and the drivers align to it, in a driver change of their own, instead of each guessing. The arm was a bare z.string() whose only documented example, "Last 7 days", no driver could parse: driver-memory recognised exactly today and a case-sensitive last N <unit> and fell every other string through to a [range, range] pseudo-window that — measured through mingo on 2026-09-05 — matched EVERY Date-typed row, 2099 included, because a Date compares above a String under BSON cross-type ordering; the SQL strategies read the same string as a single ISO day. A dashboard asking for one week silently got all of history on one backend and one day on the other, with no error on either. The string arm is now z.enum(DATE_RANGE_PRESETS) — derived from data/date-range-presets.ts, the vocabulary's single source of truth since the dashboard date filter's three copies of the list were folded into it, so the two cannot drift — and any other string is refused at parse time with one prescriptive issue at the field's own path; the runtime door answers the ADR-0112 envelope 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED (api/error-code-ledger.zod.ts). ⚠️ No D2 conversion and no stored-metadata rewrite: this value is a QUERY-time request field, not a sys_metadata shape, and the two retired dialects meant different windows on different backends, so coercing one would be the platform guessing which the author meant. Measured in this repository at the ruling: three authored 'Last 7 days', all in spec tests, and no published dashboard authors the string arm at all (the shipped console lowers presets to the array arm). ADR-0049 / ADR-0112.
    • Done when: Grep every authored timeDimensions[].dateRange string — dashboard datasets, saved analytics queries, SDK / MCP callers, in-process AnalyticsService.query calls — and rewrite each bare string that is not one of the thirteen preset names: a relative phrase to its preset ('last_7_days'), an ISO day to the two-element array [day, day]. POST /analytics/query now answers 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED naming the value and the vocabulary, so a sweep is mechanical; AnalyticsQuerySchema.safeParse reports the same issue at timeDimensions.N.dateRange. Preset names and [start, end] arrays parse byte-identically to before. A query that carried one of the retired spellings was never returning the window it named (all rows on driver-memory, one day on SQL), so re-check what the widget was supposed to show rather than trusting the old result set.
  • api-assembled-entry-split — api.AssembledInstalledPackageSchema / api.InstalledPackageAtEitherStageSchema / api.ListInstalledPackagesResponseSchema / api.GetInstalledPackageResponseSchema / api.PackageApiContracts, with the types AssembledInstalledPackage, InstalledPackageAtEitherStage, ListInstalledPackagesResponse, GetInstalledPackageResponse and their Parsed twins — imported from @objectstack/spec/api → the same names, unchanged, imported from @objectstack/spec/api-assembled — change the import path and nothing else. Every schema parses and refuses exactly what it did, the route map has the same four entries, and the JSON Schema ids are unchanged (json-schema/api/AssembledInstalledPackage.json and its three siblings are still published under api/). Every OTHER Package API declaration — the request schemas of both read doors, the install / uninstall / upgrade / rollback shapes, PackageApiErrorCode — stays on @objectstack/spec/api.
    • Why not automatic: Maintainer ruling of 2026-09-17, option B (narrow the entry, rather than add a bundle-weight rule to the browser-reachability ledger or accept the weight as it stood): split the API entry so its browser-facing half no longer carries the assembled-package declarations. Those five embed the ASSEMBLED package body, which reaches the whole metadata vocabulary and, behind it, the datasource declaration and the driver-config validators; declared inside @objectstack/spec/api, that tree was part of every bundle of the entry, and a browser module importing two string constants from it paid for all of it. Measured on the splitting PR: that module (objectui @object-ui/core column-sortability) bundles to 166,529 bytes gzipped instead of 311,124. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here.
    • Done when: No code imports any of the five names, their types or their Parsed twins from @objectstack/spec/api — each such import is a TS2305 "has no exported member" error after upgrade (TS2724 with a did-you-mean when a similarly named export exists; the suggested name is a different schema, not the replacement), and at runtime the binding is undefined. The same names import cleanly from @objectstack/spec/api-assembled. No metadata document, stored row or JSON Schema reference needs editing: the schemas and their published ids did not change.
  • api-endpoint-cache-ttl-unit-in-key — apis[].cacheTtl — the response-cache lifetime of a declared API endpoint → cacheTtlSeconds — the same lifetime, in seconds, with the unit in the key name. It still applies to GET endpoints only.
    • Why not automatic: The D2 conversion api-endpoint-cache-ttl-to-cache-ttl-seconds renames cacheTtl to cacheTtlSeconds in the apis collection and on stored endpoint rows, keeping the value, and the rename is lossless: the key always meant seconds. The judgment is whether the author knew that. The unit lived only in the description, on the same endpoint surface where rateLimit.windowMs spells its unit in milliseconds, so a value written in milliseconds — cacheTtl: 60000 meant as one minute — cached responses for almost seventeen hours, and the rename carries 60000 over unchanged. A cache that lives a thousand times longer than intended serves stale data long after the underlying records change, with no error anywhere. Only the author can say which unit each value was written in.
    • Done when: No endpoint carries cacheTtl; the parse refuses it with the rename. Every cacheTtlSeconds value is the cache lifetime the author intends in seconds — an endpoint meant to cache for one minute reads cacheTtlSeconds: 60. A GET to the endpoint repeated inside that window is answered from the cache, and one repeated after it reflects a record changed in between.
  • api-error-retry-after-unit-in-key — EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire → retryAfterSeconds — rename the key; the value (seconds) is unchanged
    • Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. BREAKING ON THE WIRE, and ruled in deliberately: its 2026-09-05 population ruling puts the ~16 runtime-emitted measurements in scope because they are read by humans and agents even if nobody authors them, and names ApiError.retryAfter explicitly, with its own BREAKING note. The ambiguity here is sharper than the usual bare duration. A consumer meets TWO retry-after values on the same 429 response: this envelope field, which has always been delta-seconds, and the HTTP Retry-After header, which per RFC 9110 section 10.2.3 may carry EITHER delta-seconds OR an HTTP-date. Spelled identically, they read as one value in two places; spelled retryAfterSeconds, the envelope states its own unit and the header keeps its own rules. THE HTTP HEADER IS A SEPARATE, UNCHANGED SURFACE — its name is fixed outside this repo and nothing in this rename touches it. Do not "fix" the header to match, and do not read a green grep for retry-after in transport code as leftover work. A SEMANTIC entry rather than a D2 conversion because an error envelope is emitted, never stored: it is not a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087, ADR-0112.
    • Done when: No producer emits retryAfter on an ApiError envelope and no consumer reads it; the old spelling is a retiredKey() tombstone that fails tsc at the construction site and fails the parse with the rename prescription. Concretely, check three things. (1) Code building a rate-limit error body: rename the key to retryAfterSeconds, value unchanged. (2) Client code backing off on a 429: read error.retryAfterSeconds. (3) The transport layer is NOT part of this change — a handler setting the Retry-After response header, and a client reading it (including the HTTP-date branch), keep the RFC 9110 spelling and are correct as they stand. A local rate-limiter decision object of the shape { allowed, retryAfter } is not this key either: it is not an ApiError envelope and is untouched.
  • api-runtime-config-durations-unit-in-key — two api-layer runtime configuration durations whose name carried no unit: DataLoaderConfig.cacheTtl (api/contract.zod.ts) and RouteDefinition.timeout (api/router.zod.ts) → cacheTtlSeconds (seconds) and timeoutMs (milliseconds) — rename each key; both values are unchanged
    • Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys: the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. Two keys on two shapes, in one entry because they share a disposition and an audience: both are api-layer runtime configuration a host or plugin builds in code, and neither is part of a published metadata document. DataLoaderConfig.cacheTtl named seconds only in its describe on a batching config whose other numbers are counts (maxBatchSize, maxConcurrency); RouteDefinition.timeout said "Execution timeout in ms" in prose and nothing else. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip the old key in silence and an unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: a DataLoaderConfig is a per-request batch-loader construction argument and a RouteDefinition is a router registration built by a plugin at start — neither is a stack collection member and neither is ever stored, so the chain has no seam that would run on them (the kernel/Manifest:loading precedent). Worth knowing while grepping: packages/runtime declares its OWN local RouteDefinition interface for the ai:routes hook payload — a different type with no duration key at all, untouched by this rename. ADR-0087.
    • Done when: Every DataLoaderConfigSchema.parse(…) and RouteDefinitionSchema.parse(…) site spells cacheTtlSeconds / timeoutMs; authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription naming the suffixed key. A DataLoader configured with cacheTtlSeconds: 60 expires per-request cache entries after 60 seconds exactly as cacheTtl: 60 did, and its min(0) bound rides along, so a negative TTL is still refused. A route declared with timeoutMs: 30000 aborts after 30 seconds exactly as timeout: 30000 did.
  • approval-position-address-role-retired — approvals position address role:<position> — the approverId filter of the approvals request list, the actorId of every decision, and a stored pending_approvers slot → position:<position>, the one spelling of a position address; a flow approver authored as { type: 'role', value: <a position name> } becomes { type: 'position', value: <the position> }
    • Why not automatic: The fourth face of the ADR-0090 D3 role retirement, beside actor-user-roles-to-positions and action-session-roles-to-positions, and like them a runtime face with no spec schema. The approvals service read role:<position> as a second spelling of position:<position> wherever it compares a slot with the caller (the "My Pending" filter, the participant gate, viewer.can_act, and the slot test of every decision), because 15.x-era slots and the stock console's identity list carried it. ADR-0090 D3 retires the word with no alias window, so once the pinned console sent position:<position> the arm came out in one edit (maintainer ruling, 2026-10-04). position:<position> is now the only position address: a role:<position> ask matches only a slot stored under that exact spelling, and a role:<position> actor is refused with 403 FORBIDDEN. The same ruling closed the one WRITER of the spelling. The deprecated role approver TYPE already resolved as org_membership_level (the org-membership tier: owner, admin, member), but when that lookup found no one the fallback slot kept the AUTHORED spelling, role:<value>, and a holder of a same-named position decided it through the arm, so the runtime silently honoured a membership-tier declaration as a position. The fallback now writes the canonical org_membership_level:<value>, and no path writes a role: slot. Two classes of pending request are therefore decided only by the privileged override, or by a reassign to a real approver: a request a 15.x-era release stored as role:<position>, and a new request from a flow that still authors { type: 'role', value: <a position name> } and whose tier lookup finds no one. No stored slot is rewritten: the ruling refused a one-time rewrite as the permanent migration debt ADR-0090's first forcing fact names. Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds. FIRST, no metadata key moves: the address is runtime DATA (a request slot, a query parameter, a decision body's actor), never a sys_metadata row, so there is no source for a declarative transform to rewrite. SECOND, the one authored shape that leads here, { type: 'role', value: <x> }, is ambiguous by construction: the deprecated alias means the membership TIER, and whether its author meant a position instead is a judgment only that author can make, so a mechanical rewrite to either type would guess. The ApproverType role alias itself is a separate retirement and is unchanged here. ADR-0090 D3, ADR-0087.
    • Done when: No client sends role:<position> as an approverId or an actorId: every such value is position:<position>, and a request routed to the position is listed for its holder, served with viewer.can_act: true, and decided by that holder with no actorId named. Every flow approver authored as { type: 'role', value: <x> } is reviewed by its author: <x> a position name becomes { type: 'position', value: <x> }, and <x> a membership tier (owner, admin, member) becomes { type: 'org_membership_level', value: <x> }. os lint reports the first as approval-approver-not-membership-tier and the second as approval-approver-type-deprecated. Every pending request whose slot reads role:<position>, or org_membership_level:<a position name> from such a flow, is either decided by a platform admin or a tenant admin of its organization (the approve or reject is recorded with via_override: true and resumes the run) or reassigned to the position's holder, who then decides it. Verify on a running app, as an admin: the pending list filtered by approverId set to each such slot address answers no rows.
  • assembled-package-body-plugins-envelope — artifact packages[].manifest.pluginsandpackages[].manifest.devPlugins — the two keys inside an ASSEMBLED package body (AssembledPackageBodySchema, ADR-0130 D4) → Declare plugins / devPlugins at the stack TOP LEVEL only — the artifact envelope, where os serve / os migrate / os dev read them and where composeStacks still concatenates them (concat is unchanged for in-memory composition). Delete both keys from every packages[i].manifest body: a multi-package artifact that carried them is rebuilt from source (os build / composeStacks(…, { manifest: 'preserve' }) no longer folds them into a body), and a hand-written packages[] entry drops them.
    • Why not automatic: A classification error, not a new special case (maintainer ruling A, 2026-09-04: both keys are artifact envelope keys, top level only, never inside packages[] — decided while one artifact was being taught to carry several co-owning packages). plugins and devPlugins were the only members of the assembled-body key set whose values are runtime ASSEMBLY instructions rather than serialisable metadata: plugins holds what a host hands to kernel.use() — live plugin instances, manifests or package names — and devPlugins is the os dev load list. Inside an artifact a package body is inert JSON, so a plugin written under packages[i].manifest could never be constructed by any loader; every reader (serve.ts, schema-migration-plugins.ts) reads the top level, and the "resolve packages[] when the top level is absent" repair every other reader took would have turned a silent skip into a boot that registers garbage. Options B (readers resolve JSON descriptions into live plugins) and C (the emitter special-cases the two keys) were refused. After the ruling, "an artifact carries metadata, a host assembles plugins" is one sentence every reader inherits. Not losslessly convertible: hoisting a body-level plugin to the envelope changes who loads it, and a live instance has no JSON form to move.
    • Done when: No packages[i].manifest in any artifact carries plugins or devPlugins; the body schema refuses either with unrecognized_keys naming the key (pinned in assembled-package-body.test.ts), and artifact-packages.ts refuses the entry at load with that path. A stack declaring plugins / devPlugins at the top level parses byte-identically to before, composeStacks still concatenates both in stack order, and manifest: 'preserve' emits package bodies without them. An existing multi-package artifact that carried a body-level plugins is rebuilt from source.
  • audience-posture-default-invite-only — system.AuthConfig.audience → explicit auth: { audience: { posture: 'open' | 'email_domain', selfRegistrationPermissionSet: '<set>' } } (deployments that intend open self-registration only)
    • Why not automatic: The default audience posture flipped when one declared posture replaced the emergent self-registration default: an UNDECLARED audience now means invite_only — email/password self-registration (and social-provider JIT sign-up) is refused with 403 SELF_REGISTRATION_CLOSED unless the address holds a pending invitation. Previously the emergent default was open self-registration with no email verification. Whether a deployment truly means to admit strangers (public portal) or was open only by accident is a security judgment no transform can make — and a posture that opens self-registration must also DECLARE the permission set a self-registrant receives and accepts forced email verification, neither of which can be invented mechanically.
    • Done when: A deployment that relies on open self-registration declares audience.posture ('open', or 'email_domain' with allowedEmailDomains) plus selfRegistrationPermissionSet, and its sign-up flow still works end to end (verification email delivered, registrant holds the declared permission set). Every other deployment verifies operators can still add users (invitation, admin create-user / import, SCIM, or an operator-registered identity provider) and that anonymous sign-up now answers 403 SELF_REGISTRATION_CLOSED.
  • automation-flow-list-route-retired — GET /api/v1/automation — the flow-list route of the automation door, together with its request and response schemas ListFlowsRequestSchema and ListFlowsResponseSchema (and their ListFlowsRequest, ListFlowsRequestParsed, ListFlowsResponse and ListFlowsResponseParsed types), FlowSummarySchema and its FlowSummary type, the listFlows entry of AutomationApiContracts, and the automation.list method of @objectstack/client. Every other automation route is unchanged, including POST /api/v1/automation (create a flow) at the same path → GET /api/v1/meta/flow — flows are metadata (ADR-0106), and this is the governed read of them; from the SDK it is client.meta.getItems with the type flow. It answers the full flow definitions rather than bare names, so a caller that only needs the names maps each item to its name. The runtime enablement and trigger binding of every flow — the one piece of engine state a definition does not carry — is GET /api/v1/automation/_status (client.automation.getRuntimeStatus), which is unchanged
    • Why not automatic: Maintainer ruling of 2026-09-25 on the list doors found declaring limit / cursor and never reading them (this route is door ④; verbatim 「退役,统一走 /meta/flow」, given when asked why the flow list does not use the standard API), under ADR-0049 enforce-or-remove. The route's contract described a capability nobody built: ListFlowsRequestSchema declared status, type, limit (default 50) and cursor, and the handler read none of them — it asked the automation service for its flow names with no arguments at all. ListFlowsResponseSchema declared a page of FlowSummary rows with total, nextCursor and hasMore, and the handler answered a bare array of names beside a literal hasMore: false. So a caller filtering by status received every flow, a caller paging with a cursor re-read the only page forever, and a caller reading FlowSummary fields read undefined — each with a 200 and no error. Measured before removal, on the main branch of this repository and cloud and on objectui at both its pinned commit and main: zero callers of the route or of the SDK method outside their own tests, while both real flow lists in the product — the Console flow-runs page and the Setup packaged-automation page — already read GET /api/v1/meta/flow. Implementing the declared contract instead would have built a second, weaker metadata list beside the governed one; retiring it leaves one read. There is no alias and no transition window: GET simply stops being mounted there. There is no D2 conversion and no tombstone, because the shape is HTTP-only — nobody authors a ListFlowsRequest and nothing persists one — so the three schemas are whole-def removals in RETIRED_DEFS_BY_MAJOR and this entry carries the record. ADR-0049 / ADR-0087 / ADR-0106.
    • Done when: On the composition objectstack serve builds, GET is no longer mounted at /api/v1/automation (nor at its environment-scoped twin), so the host gives its standard unmatched answer with no residual refusal text of its own. Because POST still lives at that path, on the Hono host that answer is 405 METHOD_NOT_ALLOWED with an Allow header naming POST — the same answer any path where only another verb is registered gets, for anonymous and signed-in callers alike. A transport that forwards every automation path to the dispatcher is told the domain does not handle it and answers its own not-found 404 (the @objectstack/hono catch-all does), and there the domain's anonymous floor still answers an unidentified caller 401 first, as it does for every automation path. The automation service's flow-name enumeration is never called by any HTTP request. The route-ledger row for the route is gone, AutomationApiContracts has eight entries and none of them is a GET at the bare path, and a TypeScript import of any of the removed schemas or types is a compile error (TS2305). @objectstack/client no longer declares automation.list, so a call to it is a compile error rather than a request to a path that no longer answers. POST /api/v1/automation still creates a flow, and every other automation route — the single-flow reads and writes, trigger, toggle, clone, runs, resume, cancel, restore-suspension, screen, _status and the actions and connectors catalogs — answers exactly as before.
  • automation-runs-cursor-retired — api.listRuns cursor — the pagination query parameter of GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on IAutomationService.listRuns, and its option on all three @objectstack/client run-list surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). The limit parameter of the same door is NOT part of this retirement and is unchanged, default(20) included → a wider limit — this door does read it, bounded to 1..100, and it is spent as the run store's history window. There is no replacement for cursor itself, deliberately: nothing ever minted one, so no caller holds a value to carry over, and the response nextCursor it would have paired with has never been emitted. Read the response hasMore to learn whether the window was short — it is now computed from the engine rather than the constant false it used to be, so for the first time it answers the question a caller reaching for a cursor was actually asking
    • Why not automatic: ADR-0049 enforce-or-remove (maintainer ruling 2026-09-21 on the list doors found declaring limit / cursor and never reading them — this door is door ①, and the ruling took letter C of three for it; letter A — build a cursor protocol for a 100-row window — and letter B — retire the key and leave the hasMore lie standing — were both considered and refused). cursor was declared on the request, VALIDATED at the boundary, forwarded into a cursor?: string slot on the service contract, and read by no implementation: the engine never looked at the option, and no emit site has ever written the response half nextCursor, so a caller looping until the cursor ran out re-read the first and only window forever with no error. ⭐ The limit half of this door was NOT retired, and the distinction is the ruling, not an oversight. The sibling /packages door retired its limit with its cursor (the 2026-09-13 ruling aligning that door's declaration with its reads: pagination is no part of a small bounded list) because nothing read it; the parent ruling explicitly does not transfer here. On this door limit is read end to end — the boundary enforces the declared 1..100 range off the schema itself, the service takes it as an option, and the engine spends it as RunStore.listHistory's window — and the Console's flow-runs page sends it today. Retiring it would have been a regression, and its .default(20) stays with it. The same card computes hasMore, which is the half a bare retirement would have left lying. GET /api/v1/automation/:name/runs shipped a literal hasMore: false beside a list the engine had already truncated with .slice(0, limit), so a caller asking for one row of a thousand was handed one row and told that was all of them. The engine now reports truncation to the door through a new optional contract member, IAutomationService.listRunsPage, which returns { runs, hasMore }: it over-reads its history source by exactly one row and compares the merged, filtered, ordered set to the caller's window. The over-read is what makes the answer sound — runs.length === limit cannot tell a flow with exactly limit runs from one with ten thousand, and RunStore.listHistory's signature is deliberately unchanged because over-reading is expressible in the limit it already takes. There IS a tombstone: the request schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this card's own defect re-created one layer down (ADR-0104). cursor is therefore a retiredKey(), typed never for tsc and raising the prescription at any parse, and is registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored sys_metadata row, and this shape is HTTP-only — nobody authors a ListRunsRequest and nothing persists one. The os migrate meta house sentence is therefore correctly absent from the prescription. There is no acceptRetiredDefaultResidue stage either: cursor carried no default, so it materialized into no artifact and there is no residue to accept. The SDK half is part of the retirement rather than a follow-up: @objectstack/client declared cursor and appended it on all three run-list surfaces, so retiring the key in the schema alone would have left the one generated client this repo ships typing it string and sending it into a route that silently drops it — the ADR-0104 shape the tombstone exists to prevent, re-created one layer down. The same call was made when the notifications cursor was retired: the client dropped the option and recorded the removal in its docblock. ADR-0049 / ADR-0087.
    • Done when: No caller sends cursor to GET /api/v1/automation/:name/runs, and that is true of every channel this repo ships rather than of the schema alone. Writing it on a ListRunsRequest is a tsc error (the input type is never), and any value reaching a parse raises the prescription rather than a generic unrecognized-key issue. The option is gone from IAutomationService.listRuns, so an implementation can no longer declare a slot for it. ⭐ It is also gone from the SDK, which is the channel most callers actually reach this door through: @objectstack/client no longer declares cursor on automation.runs.list, automation.listRuns or client.environment(id).automation.listRuns, and no longer appends ?cursor= on any of the three — so the key cannot be smuggled past the retired schema by an untyped caller. Without that half the retirement would have re-created its own defect one layer down: the schema typing the key never while the shipped client typed it string and sent it, silently dropped by a route that no longer reads it (ADR-0104). ⚠️ ONE wire behaviour CHANGES and must be verified as such, because it reverses a decision recorded when this door's query parameters were first validated where they are read (the fix for ?limit=abc reaching the engine as NaN): a repeated ?cursor=a&cursor=b used to answer 400 VALIDATION_FAILED with a details.fields[] entry naming cursor, and now answers 200 with the key ignored like any other unrecognised query name. That fix validated the key rather than deciding it, so that a future cursor implementation would not be the one to discover the type was unenforced; this ruling decides it instead — there will be no cursor implementation on this door — so the refusal would be validating a key the contract no longer has. This route declares no closed query set (AGENTS.md route-ownership rule 5), so an unrecognised name has never been refused here on its own account. ⚠️ hasMore also changes, from a constant to an answer: a request whose window is shorter than the matching run set now receives hasMore: true where it previously received false. A caller that treated false as "this is the whole history" was always wrong and is now told so. ⚠️ Read the new false with one qualification: unfiltered it is exact, but under ?status= it means "no further match inside the window that was scanned" rather than "none exists", because the durable history source has no status slot and the window is taken before the filter is applied. Pushing the filter down is a store-contract change this card did not scope. nextCursor stays absent — nothing mints one — and limit behaves exactly as it did, including its .default(20). A deployment whose automation service does not implement listRunsPage answers 501 naming the member, and ⛔ never a 200 carrying an invented hasMore.
  • autonumber-default-unique-organization — ``fields..uniqueon atype: 'autonumber'field when the author OMITS the key — the contract default moves fromfalse(no index) to'organization'(one holder per organization; the NULL-safe tenant-composite unique index(COALESCE(organization_id, 'global'), ) on an organization-scoped object, a plain unique index where the object has no organization key) → keep the omission to take the default — an auto-number is a business identifier and is unique per organization from now on with zero application-side declaration; write unique: false EXPLICITLY on the one autonumber field that is a display-only sequence and is never used to identify the record. Every other field type keeps unique: false as its default, and every authored spelling (true / 'organization' / 'global' / false) parses exactly as before
    • Why not automatic: Not losslessly convertible because the change is data-dependent, not textual: a table that already holds duplicate auto-numbers (a counter that re-issued a burned number, or the seed/API tenancy split running two counters for one object) cannot take the index the default now declares. The SQL driver refuses to silently degrade — it logs at error naming the index, the columns and the remedy, the same boot's drift pass names the conflicting key groups with row counts, and os migrate plan reports the blocked create_index with the same groups (ADR-0120 D4) — but which of the duplicate rows keeps the number is a business decision no migration entry can make. Maintainer ruling 2026-08-31, on a downstream CRM's measurement that eight of its nine auto-numbered business identifiers could be issued twice: an auto-number that may repeat is not an identifier, so unique is the platform default and opting out is the declaration, not the other way round.
    • Done when: Every autonumber field without an authored unique parses to unique: 'organization' (FieldSchema.parse({ type: 'autonumber' }).unique === 'organization', and through ObjectSchema the same); an authored unique: false on an autonumber field parses to false; every non-autonumber field type without an authored unique still parses to false at the same key position. On a serving boot, each organization-scoped object with such a field carries uniq_<object>_organization_id_<field>; a table whose data blocks it shows the blocked create_index with its conflicting groups in os migrate plan until the rows are deduplicated and the plan is re-run, and os migrate duplicates lists the holder rows of any value minted across organization partitions.
  • branded-identifier-schemas-retired — the six branded identifier schemas of @objectstack/spec/shared (shared/branded-types.zod.ts, removed whole): ObjectNameSchema, FieldNameSchema, ViewNameSchema, AppNameSchema, FlowNameSchema, RoleNameSchema, and their type exports (ObjectName/ObjectNameParsedthroughRoleName/RoleNameParsed). → (removed — no replacement brand layer. Parse an identifier through the schema of the surface that stores it: object and field names through ObjectSchema/FieldSchema (inline snake_case regex), flow names through FlowSchema, app names through AppSchema (SnakeCaseIdentifierSchema), position/role names through PositionSchema. A caller that wants a standalone identifier check uses SnakeCaseIdentifierSchema or SystemIdentifierSchema from @objectstack/spec/shared directly — both stay published.)
    • Why not automatic: Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The brands promised compile-time safety ("you cannot pass an ObjectName where a FieldName is expected") that no consumer could obtain: no schema in either repository ever composed a brand, so nothing produced or accepted a branded value, while the surfaces the brands were named for are validated by inline regexes or bare SnakeCaseIdentifierSchema three files away. Binding was weighed and not adopted: zero consumers exist, binding would silently change five surfaces' accept sets (the inline regexes admit a leading underscore the brand base does not), and a future real need for centralized identifier grammar re-opens freely against actual pull.
    • Done when: No code imports any of the six schemas or their types from @objectstack/spec/shared (TS2305 after upgrade — the module is removed, not stubbed); the five surfaces' validators are byte-for-byte untouched (inline regexes at data/object.zod.ts, data/field.zod.ts, automation/flow.zod.ts; bare SnakeCaseIdentifierSchema at ui/app.zod.ts, identity/position.zod.ts); SnakeCaseIdentifierSchema and SystemIdentifierSchema themselves remain published and unchanged; the six def keys (shared/ObjectName, shared/FieldName, shared/ViewName, shared/AppName, shared/FlowName, shared/RoleName) leave json-schema.manifest/shared.json in the same change that registers this entry. No authored metadata document ever embedded a branded value, so no source rewrite ships and objectstack migrate meta has nothing to visit.
  • by-id-write-unreadable-row-not-found — the data write doors — a by-id update or delete of a row the caller cannot read, on every object and for every principal → read 404 RECORD_NOT_FOUND on a by-id update or delete as "no row you can see has this id" — the read door's meaning — and keep a 403 for a row the caller can read but may not write
    • Why not automatic: A WRITE-DOOR ANSWER, made one with the read door's. A by-id update or delete of a row the caller cannot read used to answer a 403 — PERMISSION_DENIED where a write-class row filter binds the caller, otherwise a later gate's own 403, such as FORBIDDEN from record sharing or a parent-derived gate's code on attachments and comments — while an id that names no row answered 404, so the write door told a hidden row apart from a missing one. The by-id write pre-image check now asks every principal whether it can read the row it addressed, through a by-id read in its own context that every data middleware's visibility applies to, and answers a row that read does not return with the read door's not-found: the same code, status and body a nonexistent id gets. It also refuses a by-id write a principal no row filter binds could previously land on a row hidden from it, such as an attachment's uploader or a comment's author whose parent record they can no longer read. A caller who can read the row but may not write it keeps its 403. Writes the platform issues under the caller's context — the engine's cascade delete, a hook's write, the referential clear of a lookup — keep their previous answer, and writes not routed by id are unchanged.
    • Done when: Every client that handles a by-id update or delete treats 404 RECORD_NOT_FOUND as "not found or not visible" and no longer reads a 403 there as proof the row exists; an operator who needs a user to write a row grants that user read access to it first.
  • cache-warmup-scheduled-strategy-retired — CacheWarmup.strategy — the value 'scheduled' left the warmup-strategy enum (packages/spec/src/system/cache.zod.ts), and the enum describe stopped promising "scheduled (cron)". The key itself, DistributedCacheConfig.warmup.strategy, is unchanged and still authorable → 'eager' to warm at startup or 'lazy' to warm on first access — the two strategies the vocabulary ever described without pointing outside itself. There is no replacement for the cadence: a warmup on a schedule is a job. Declare a job with schedule.expression (system/job.zod.ts) whose handler does the warming — that is the one cron slot this platform evaluates, and it is the slot deliberately kept when the seven cron-typed positions nothing read were deleted
    • Why not automatic: ADR-0049 enforce-or-remove, closing the residue the retirement of the seven cron-typed positions nothing reads left inside the schema it had just edited. That retirement deleted CacheWarmup.schedule — the cron key this enum member selected — and declined the member itself on the reading that it is "a value, not a position this ruling names". That is a statement about the ruling's SCOPE, not a finding that the value was sound: after the deletion the member declared a warmup cadence with no key left to configure it, no engine that has ever run one, and a .describe() still promising "(cron)" — ADR-0049 declared-not-enforced in the form Prime Directive 10 names outright, a capability advertised that the runtime does not deliver. Re-measured on main at 690f083f83 with a lit control rather than inherited from the card: CacheWarmupSchema has zero runtime consumers outside its declaring file (six files reference it — the generated reference page import, the declaration-map and export-origins catalogues, the ADR-0058 D7 ledger comment and two spec test files — while the control, ConnectorSchema, resolves to 46 files), and no cache-warmup engine exists anywhere on the platform. Bookkeeping follows the hot-reload-inert-state-strategies-retired and crypto.hash precedents: an enum-VALUE narrowing puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed, and they key on positions and names, never on a def's value set), so the prescription hangs on the enum's own error map dispatched by issue.input — telling the author of a TYPO that their value "was removed" would misinform. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: CacheWarmup is bound to no metadata type and embedded in no stack collection, so no authored document and no stored row has ever carried this value, and os migrate meta has nothing to list. Route 3 of the retirement playbook, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement: this entry IS the declaration. ADR-0049, ADR-0087.
    • Done when: No configuration passes strategy: 'scheduled' to CacheWarmupSchema or to DistributedCacheConfigSchema.warmup. TypeScript callers cannot: CacheWarmup['strategy'] is now 'eager' | 'lazy', so the literal is a compile error at the authoring site. Callers that arrive as JSON get a parse REFUSAL — not the silent strip the cron-position retirement left for the schedule key beside it, because a narrowed enum rejects rather than drops — carrying the prescription, which names the job route. Concretely, check two places. (1) Any host or deployment config embedding a DistributedCacheConfig: a warmup block selecting the retired strategy now fails to parse where it previously parsed green; change it to eager or lazy. (2) Anything that was waiting on the cadence to take effect: it never did. No warmup has ever run on a schedule on this platform, so migrating the value changes no runtime behaviour whatsoever — what changes is that the contract stops promising it. If a scheduled warmup is genuinely wanted, it comes back through the ENFORCE leg of ADR-0049: the engine first, the declaration with it, never as a bare enum row again.
  • cbp-master-detail-required-forced — object.fields.<master>.required on a master_detailreference undersharingModel: 'controlled_by_parent'— authored viaObjectSchema.create()`` → required: true on the master reference (or nothing at all — the builder now forces required: true when the key is omitted). An explicit required: false on that shape is refused at ObjectSchema.create() with a located error carrying this same prescription. Metadata at rest is untouched: raw .parse()/.safeParse() still accept the old shape, the security gate's derived enforcement stays, and the lint rule relationship/master-detail-required stays warning until its own v18 promotion (Direction 1 of the 2026-08-16 maintainer ruling whose Direction 2 this is)
    • Why not automatic: A controlled_by_parent detail derives ALL of its record access from the master that its master_detail reference names (ADR-0055). With the reference not required, an insert may omit the master FK: the row lands with a null FK that the derived read filter masterFK IN (accessible master ids) can never match — unreadable by everyone — and every later by-id write answers 422 MISSING_REQUIRED_FIELD. The finding behind the ruling measured that only the security gate closed this shape while the declaration surface still accepted it. The maintainer ruling (2026-08-16, Direction 2) makes the unsafe shape impossible to NEWLY declare at the builder; whether to keep required: false was never a real choice (the value contradicts the sharing model), so the flip is forced rather than convertible — and an explicitly authored false is refused rather than silently rewritten (ADR-0032 "no silent failure").
    • Done when: Every ObjectSchema.create() call declaring sharingModel: 'controlled_by_parent' either omits required on its master_detail reference(s) (now emitted as required: true) or declares required: true explicitly; the authored app builds and boots. An explicit required: false there fails the build with ObjectSchema.create(...): field ... declares required: false on a master_detail reference under sharingModel: controlled_by_parent. Stored metadata keeps loading byte-identically (safeParse green, required unrewritten).
  • cbp-master-detail-required-lint-error — object.fields.MASTER.required / .readonly / .system, where MASTER is a master_detailreference on an object declaringsharingModel: 'controlled_by_parent'— as judged byos lintunderrelationship/master-detail-required`` → required: true on every master_detail reference of a controlled_by_parent object, with neither readonly: true nor system: true on it: declare the master reference as an ordinary required field. os lint now reports each of the three unsafe shapes there — required absent or false; required: true + readonly: true; required: true + system: true — at error under relationship/master-detail-required, so os lint exits non-zero and the metadata-generation rubric marks the stack invalid. On every other object the rule is unchanged: a warning for a master_detail without required: true, and no finding for the two flagged shapes.
    • Why not automatic: A controlled_by_parent detail derives ALL of its record access from the master that its master_detail reference names (ADR-0055). Record validation never checks a field that is not required, and skips readonly and system fields before its required check is reached, so on these three shapes nothing but the security gate refuses an insert that omits the master FK. A record that lands without it anyway is readable by nobody — the derived read filter masterFK IN (accessible master ids) never matches null — and every later by-id write is refused. Before this step the lint predicate was required !== true at warning on every object: the two flagged shapes drew no finding at any severity, and the third drew a warning that an author or a generator could ignore. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary as a deliberate narrowing of the authoring contract. Its builder half (the cbp-master-detail-required-forced entry) forces required: true at ObjectSchema.create but never inspects readonly or system, so two of the three shapes still pass the builder and meet their first authoring-time refusal here, and the third still reaches it from any object not authored through the builder. Runtime tolerance is unchanged on purpose: the security gate keeps refusing these inserts, and keeps resolving the master for metadata already at rest.
    • Done when: os lint reports no relationship/master-detail-required finding at error: on every object declaring sharingModel: 'controlled_by_parent', each master_detail reference declares required: true (or omits it and is authored through ObjectSchema.create, which emits required: true) and carries neither readonly: true nor system: true. ⚠️ WHICH DOOR: the refusal is os lint's and the metadata-generation rubric's only. The rule is not in the authoring-rule registry, so os build, os validate and the metadata save door do not run it, and a stack carrying the shape still builds and publishes — read os lint's exit code, not a green build. Stored metadata is not rewritten and keeps loading, and the security gate still refuses an insert that omits the master FK on these shapes. Repo census at the time of the change: 129 authored objects across the example apps, the platform and plugin objects and the CLI's golden eval corpus, 7 of them controlled_by_parent, 0 findings at error.
  • cel-predicate-list-comparand-refused — security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with != or == against a list, either a list literal or a current_user membership set the runtime resolves to an array (org_user_ids, positions, accessible_org_ids, or a key staged into rlsMembership), and the negation of such a comparison. On driver-mongodb, also a query filter carrying $ne with an array comparand, at any depth under $and / $or / $not → the list operator the comparison was standing in for. "One of these values" is in: record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids. "None of these values" is the negated in: !(record.status in ["closed", "archived"]). In a query filter, $in and $nin. Scalar != and ==, null, in, and field-to-field comparisons lower exactly as before
    • Why not automatic: The @objectstack/formula pushdown compiler lowered such a comparison to a $ne carrying the array, to a bare-array equality, or to a $not around one. A row-level using clause is composed into the query after the engine's comparand-shape check, and driver-mongodb passed the shape to the server: measured through mingo, the named proxy for MongoDB query semantics, $ne against an array and the $nor that a negated equality becomes selected every row storing a scalar, so the read returned the rows the policy was written to hide. A check written != against a membership set admitted and stored every write, on driver-sql as on driver-mongodb. The compiler now refuses the comparison with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows and check writes are refused 403. A declared sharing rule with such a condition is skipped at bootstrap and never seeded. The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time. driver-mongodb refuses $ne with an array comparand with INVALID_FILTER / 400, as driver-sql and driver-memory already do. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087.
    • Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for != or == whose other side is a list literal or a current_user membership set, and for the negation of such an ==, then rewrite each with in or its negation. On driver-mongodb, grep stored query filters for $ne with an array value and rewrite each with $nin.
  • cel-predicate-one-value-comparand-refused — security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate whose comparison is handed something other than one value: an ordering operator (>, >=, <, <=) against a list literal or a current_user membership set; an in list with a member that is itself a list; a comparison with no field at all against a membership set (current_user.org_user_ids != "x"); an ordering operator against the current_user root or a key that resolves to an object; and a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does. In a filter passed to matchesFilterCondition, also $gt / $gte / $lt / $lte with an array, and $in / $nin with an array member → the comparison the predicate was standing in for. "One of these values" is in: record.status in ["open", "pending"], or record.reviewer_id in current_user.org_user_ids; "none of these values" is !(record.status in ["closed", "archived"]), with the list flat. An ordering takes one bound: record.status > "m", and a range is two comparisons joined by &&. A comparison against the caller names one key: record.reviewer_id > current_user.id. A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. One-value comparisons, flat in lists, and field-to-field comparisons between single-valued columns lower and evaluate exactly as before
    • Why not automatic: Ruling A of 2026-09-24 refused a list under != and in the equality slot, holding both to the declared comparand — a literal or a { $field } reference; stage 2d closes the same fault one position over, measured through the real plugin-security on driver-sql and driver-memory. !(record.status in [["closed", "archived"]]) lowered to a negated $in whose only member was a list, which the strictly comparing write-check evaluator matched on no record, so the negation admitted and stored every write, and driver-memory returned every row on a read. record.status > ["m"] compared the list as the string "m". current_user.org_user_ids != "x" and current_user.org_user_ids > "a" folded to "no restriction": every write admitted and every row read. record.reviewer_id > current_user compared the whole caller object as a string. record.status != record.tags, with tags a json or multiple field, matched every post-image, so the check admitted and stored every write. The CEL compiler now refuses the first four with reason unsupported, so the RLS compiler drops the policy and fails closed when no other policy applies (reads return no rows, check writes are refused 403, the analytics read scope is the deny scope) and a declared sharing rule is skipped at bootstrap; the authoring lint reports what the source shows (a list literal, the current_user root). The compiler cannot see a column's type, so the last is refused by the write-check evaluator on the record whose compared column holds a list or an object: INVALID_FILTER / 400, nothing stored. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison a predicate was standing in for, and rewriting it on the author's behalf would change which writes and rows it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112.
    • Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for an ordering operator next to a list or to current_user alone, for an in list that nests a list, for a comparison with no record field against a current_user membership key, and for a field compared with a json or multiple field; rewrite each as the replacement says. Then re-check what each policy is supposed to admit rather than assuming what it admitted before was right: several of these admitted every write, and two folded to no restriction at all.
  • cel-predicate-variable-root-comparand-refused — security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing with != or == against the bare current_user root, the variable with no key named after it, whether the other side is a field or a literal, and the negation of such a comparison. For a caller of the published compiler that binds its own variables, also a variable that resolves to an object → the key of current_user the comparison means: record.owner_id == current_user.id, or current_user.organization_id, or current_user.email. A membership test is in: record.owner_id in current_user.org_user_ids. Scalar keys, membership sets under in, literals, null and field-to-field comparisons lower exactly as before
    • Why not automatic: The @objectstack/formula pushdown compiler resolved the bare root to the whole caller context object, every kernel-resolved key at once with the membership arrays included, and lowered the comparison to a $ne carrying that object, to a bare-object equality, or to a $not around one; a constant comparison such as current_user != "guest" folded to no restriction. A strict compare never equals an object, so through the real SecurityPlugin on driver-sql a check written != against the root, or its negated ==, admitted and stored every insert and by-id update it was written to refuse, a USING-only such policy admitted every insert on the write pass, and explain reported the read as narrowed with the caller membership sets echoed in its readFilter. ADR-0058 D2 declares the operand opposite a field as a literal, a current_user scalar or a pre-resolved current_user set, and the published $eq / $ne contract declares a literal or a { $field } reference; the root is none of them. The compiler now refuses it with reason unsupported in both of its modes, so the authoring lint reports it (rls-predicate-unenforceable on either clause, sharing-rule-unlowerable-condition on a sharing condition), and the RLS compiler drops the policy and fails closed when no other policy applies: reads under it return no rows, check writes are refused 403, and explain answers denies. A declared sharing rule with such a condition is skipped at bootstrap as it already was, now with reason unsupported instead of unresolved-variable. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which key the author meant, and a policy rewritten on the author's behalf would change which rows it admits. ADR-0058 D2 / ADR-0087.
    • Done when: Grep the rowLevelSecurity using and check predicates of your permission sets, and the condition of your sharing rules, for != or == whose other side is current_user with no key after it, then rewrite each against the key it means (current_user.id, current_user.organization_id or current_user.email), or with in against a membership set.
  • change-management-duration-keys-retired — change-management duration keys: ChangeImpact.downtime.durationMinutes, RollbackPlan.steps[].estimatedMinutes, ChangeRequest.implementation.steps[].estimatedMinutes`` → nothing to re-declare — delete the keys. No change-management engine exists on the platform: nothing schedules a maintenance window, executes or times an implementation or rollback step, or compares an estimate with what happened, so there is no live mechanism to declare a duration to
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Three minute-shaped keys, at three nested sites, sat in the exported change-management schemas and in the generated reference docs — an author could write estimatedMinutes: 15 on a rollback step and reasonably expect it to feed a schedule — and read by NOTHING: the schemas are exported from @objectstack/spec/system, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. All three sites are NESTED (downtime.durationMinutes, steps[].estimatedMinutes twice), so the authorable-surface ratchet — which walks top-level def properties — never listed them; their RETIRED_KEYS_BY_MAJOR[18] entries carry the nested spelling for the spec-changes / upgrade-guide projection. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent).
    • Done when: No ChangeImpact.downtime block carries durationMinutes, and no implementation or rollback step — in a RollbackPlan or inside a ChangeRequest — carries estimatedMinutes. TypeScript authors get the refusal at compile time (each key is typed never); a value reaching the parse is refused with the prescription (invalid_type at the nested path of the key, e.g. rollbackPlan.steps.0.estimatedMinutes). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour.
  • change-management-family-retired — the change-management family, retired whole: the six defs system/ChangeImpact, system/ChangePriority, system/ChangeRequest, system/ChangeStatus, system/ChangeType and system/RollbackPlan, and every name system/change-management.zod.ts exported from @objectstack/spec/system (the six *Schema consts, their z.input aliases and the ChangeRequestParsed alias) → nothing to re-declare — no change-management engine exists on the platform, so there is no working configuration to migrate to. Nothing routed a change request for approval, walked its implementation steps, honoured a rollback plan or gated on securityImpact.requiresSecurityApproval / approval.required; a change record the organisation keeps is ordinary object data, declared as an object with its own fields, and an approval that must actually gate something is a flow (ADR-0018) with an approval node. Metadata change tracking on the platform is sys_metadata history and the package model (ADR-0126), unrelated to this vocabulary. If ITIL change management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Six defs and roughly fifty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from @objectstack/spec/system, mounted by no stack.zod.ts key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded), over examples/** and skills/**, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. ChangeRequest.approval.required and ChangeRequest.securityImpact.requiresSecurityApproval read as gates the platform enforced, and neither ever did — the worst form of the declared-but-unenforced shape, on a security-adjacent surface. Tagging the family [EXPERIMENTAL — not enforced] was the fallback the ruling did not take (a human-only signal). The duration-key tombstones of the 2026-09-02 per-family ruling (three nested sites, RETIRED_KEYS_BY_MAJOR[18], D3 change-management-duration-keys-retired) leave with their defs' source; their registry entries stay as history. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent), and with no carrier key there is no shape on which a tombstone could sit.
    • Done when: No code imports ChangeImpactSchema, ChangePrioritySchema, ChangeRequestSchema, ChangeStatusSchema, ChangeTypeSchema or RollbackPlanSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. kernel/MetadataChangeType (M92 of the type-alias pin, a different declaration with a live consumer) is unaffected. The six defs are absent from json-schema.manifest/system.json, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour.
  • chart-config-aria-retired — dashboard.widgets[].chartConfig.aria / report.chart.aria / report.blocks[].chart.aria — the ARIA block on a chart config → The sibling description, which the chart renderer lowers onto the chart graphic as its accessible name (role="img" with an aria-label). One accessibility vocabulary per chart node.
    • Why not automatic: The D2 conversion chart-config-aria-removed deletes aria from every dashboard widget chart config, report chart and report block chart, and the delete is lossless: no chart renderer on either face ever applied the block, so the ARIA attributes it declared never reached the DOM. The residue is accessibility work the author did that no user benefited from. An author who wrote aria.label for a chart believed screen-reader users heard that name; they heard the description if one was set, and nothing specific if not. The strip deletes the label text along with the key, and only the author can say whether that text should become the chart's description — a field that other readers of the chart may also show — or whether the existing description already says it.
    • Done when: No chart config on a dashboard widget, a report or a report block carries aria; the parse refuses it. Every chart that had carried an aria.label has a description conveying what that label was meant to announce, or the author has confirmed the existing description does. With a screen reader, focusing the chart graphic announces the description as its name.
  • cli-command-contribution-retired — kernel.cliCommandContribution (the orphan exported schema of cli-extension.zod.ts— 1 def, 2 exported names:CLICommandContributionSchema/CLICommandContribution) → (removed — there is no declarative replacement, because no declarative surface ever carried it. CLI commands are registered through oclif's native plugin discovery: the plugin package declares an oclif section in its own package.json — OclifPluginConfigSchema in the same module describes that live surface and SURVIVES, as does the module docblock's Commander.js migration record, which the manifest.contributes.commands tombstone cites)
    • Why not automatic: ADR-0049 enforce-or-remove, applied to the exported orphan-value-schema class: an exported schema with no consumer reads as a capability, the lesson of the plugin sandboxing / integrity / approval config that was never wired to anything. The schema described a "CLI Command Contribution declaration in the manifest" and claimed retention "for describing command metadata in plugin manifests" — but after the retirement of the plugin manifest's nine dead contributes members tombstoned manifest.contributes.commands (protocol 18), no manifest surface could legally carry these entries: the export advertised a shape whose only declared carrier rejects it. The manifest never referenced this schema even before the tombstone — its inline commands item schema was an independent duplicate. Zero consumers outside spec's own test and generated artifacts, measured at the retirement's base commit (146f448a5) with positive controls in objectstack, objectui (pinned sha) and cloud. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the advanced plugin-lifecycle config's retirement and of the retired ApiKeySchema — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration.
    • Done when: No code imports CLICommandContributionSchema or CLICommandContribution from @objectstack/spec or @objectstack/spec/kernel — both are TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in kernel/cli-command-contribution-retirement.test.ts). No metadata document needs editing: the def was reachable from no metadata-type binding, stack collection or manifest embed — the only surface that ever claimed to carry command contributions (manifest.contributes.commands) already rejects the key with its own tombstone prescription, which is unchanged by this retirement. OclifPluginConfigSchema / OclifPluginConfig survive on ./kernel (same pin). ⚠️ Runtime behaviour is deliberately UNCHANGED: the CLI never resolved commands from this declaration — commands are oclif-auto-discovered, before and after.
  • client-envelope-convergence-analytics-automation — client.analytics.query / client.analytics.meta / client.analytics.explain / client.automation.trigger — the resolved value of four published @objectstack/clientmethods: the runtime dispatcher's{ success, data }envelope before, itsdata member after → the payload — r.data.X → r.X on all four. client.analytics.query, called with a query, now resolves to AnalyticsResult: r.data.rows → r.rows. client.analytics.meta, with or without a cube name, resolves to AnalyticsMetadataResponse['data'], the bare cube list: r.data[0].name → r[0].name. client.analytics.explain resolves to AnalyticsSqlResponse['data'], { sql, params }: r.data.sql → r.sql. client.automation.trigger, given a trigger name and a payload, resolves to AutomationResult: r.data.status → r.status and r.data.runId → r.runId — the same value client.automation.execute already answered for the same handler. Same call, same wire body, one SDK calling convention
    • Why not automatic: ObjectStackClient had two response readers. unwrapResponse strips the runtime dispatcher's { success, data } envelope and hands back data; every other dispatcher-served method already used it, and these four alone ended return res.json(), so their callers alone had to read .data. All four now end return this.unwrapResponse(res) and their return declarations are the payload types. THE WIRE IS BYTE-IDENTICAL: every route answers exactly the body it answered before, no Zod schema moves, no packages/spec declaration moves, no authorable key and no stored representation is involved — the landing diff touches no packages/spec path at all — so a raw-HTTP caller is unaffected and objectstack migrate meta has nothing to rewrite. This is registered rather than exempted because the change is NOT wholly compiler-delivered, and the gap is exact rather than theoretical. For the three analytics methods it is: every old read is error TS2339: Property 'data' does not exist on type …, so tsc names each site. client.automation.trigger is the exception — AutomationResult itself declares success: boolean and error?: string (AutomationResult in packages/spec/src/contracts/automation-service.ts, byte-identical at the merge base and at this landing), so r.success and r.error COMPILE ON BOTH SIDES while their meaning moves: before, r.success was the envelope's flag — always true on a resolved call — and r.error was never set on a 2xx; now they are the run's own, and a refusal the door does not classify as 400 / 409 / 422 is answered 200 carrying success: false with error set. A consumer branching on either reads a DIFFERENT QUESTION at the same spelling, with no diagnostic anywhere. And there is no authored source for the conversion chain to rewrite: 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 — .data simply reads undefined — which is why the ledger entry is the only notification that reaches them. That is the same argument the two sibling entries on this package make (client-delete-result-success, client-meta-reset-result-reset), and this one is the stronger case of the three: those corrected declarations that were UNINHABITED, revealing a defect rather than breaking working code, whereas this moves reads that work today. ⛔ Do not write r.rows ?? r.data.rows: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. The failure path is unchanged and deliberately so — ObjectStackClient.fetch rejects on every non-2xx BEFORE either reader runs, carrying the ADR-0112 error envelope, and unwrapResponse itself never throws. ADR-0087 D3.
    • Done when: No code reads .data off a client.analytics.query, client.analytics.meta, client.analytics.explain or client.automation.trigger result. For the three analytics methods tsc names every site for a typed caller (TS2339); an untyped JS caller must be swept by hand for the four spellings, because nothing will report it. ⚠️ client.automation.trigger needs the hand sweep even WITH a type-checker: every branch on r.success or r.error off trigger has to be re-read one by one, because both compile before and after while their subject moved from the envelope to the run. A branch that treated r.success as "the call was accepted" now asks "the run succeeded" — the two differ on exactly the 200-answered refusals — and a catch-only error path now has a resolved success: false sibling it never had to consider. Nothing about the request, the route, the status codes or the thrown error shapes changes, and no server needs upgrading: the value you may now read is the one that was already arriving, one level in. client.analytics.queryDataset is NOT part of this move — it is served with no envelope at all and resolved to the bare payload before and after. Populations: in the ObjectStack repo, zero production call sites and the loud test pins that ship with this change; in objectui, one production row-extraction chain that tolerates both spellings today and is tightened to the post-unwrap spelling once this lands; objectstack-ai/cloud is NOT MEASURED — a .data read on any of the four there is a runtime break after this change, and this entry is the only notice it gets.
  • client-meta-reset-result-reset — client.meta.deleteItem(...).deleted / .type / .name (the return of client.meta.deleteItem()and the environment-scopedclient.environment(id).meta.deleteItem()) → reset — r.deleted → r.reset. Same call, same wire body, declared shape. Both twins now declare DeleteMetaItemResponse (@objectstack/spec/api); type and name have no replacement because the reset door never echoed them — the caller already holds both, it passed them in
    • Why not automatic: Both deleteItem declarations on @objectstack/client declared Promise<{ type: string; name: string; deleted: boolean }> while DeleteMetaItemResponseSchema declares { success, reset?, message? }. The declaration was not merely imprecise, it was UNINHABITED: DELETE /meta/:type/:name ends in res.json(result) with deleteMetaItem's return, and not one of that method's four return branches carries type, name or deleted. Both surfaces are pure unwrapResponse / _unwrap passthroughs — and the reset body carries no data key, so nothing is stripped — which makes the declaration a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed a spelling no server has ever sent. if (r.deleted) compiled and read undefined on EVERY reset, including the ones that really removed an overlay row; if (r.reset) was rejected by the compiler and correct on the wire. So this 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. The truthful flag also carries the distinction the phantom one could not express at all: reset: true means an overlay row was deleted, reset: false means none existed and the item was already at its artifact default. Registered as a semantic entry rather than a mechanical conversion for the reason the rewrite does not capture: 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 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 this entry is the only notification that reaches them. ⛔ Do not write r.reset ?? r.deleted: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent. No deprecated deleted?: boolean transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. The identical correction one door over is client-delete-result-success; the wire is deliberately untouched here, per the 2026-08-29 ruling that reality is the contract. ADR-0087.
    • Done when: No code reads .deleted, .type or .name off a client.meta.deleteItem() / client.environment(id).meta.deleteItem() 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. Cache invalidation, registry refreshes and UI reloads guarded that way have never run, and switching to r.reset turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Note reset is OPTIONAL in the contract and distinguishes two successful outcomes, so if (r.reset) and if (r.success) are different questions: the former asks whether a row went away, the latter whether the call was accepted. Any test that passed while asserting on deleted was asserting on undefined and needs rewriting, not renaming.
  • client-oauth-applications-delete-void — client.oauth.applications.delete(clientId) — both halves of what a caller of this published @objectstack/clientmethod observes: the DECLARED return,Promisebefore andPromiseafter, and the SETTLE BEHAVIOUR, which rejected withSyntaxError: Unexpected end of JSON input on every successful delete before and resolves after → no value — void. There is nothing to move a read TO, because the promise never resolved for a caller to read anything off it. The migration is on the settle path instead: try { await client.oauth.applications.delete(id); } catch { /* it probably worked */ } → drop the workaround, the catch was executing on EVERY successful delete and now executes only on a real failure. A read off the resolved value — (await client.oauth.applications.delete(id)).deleted — was unreachable code that has never executed and now stops compiling (TS2339). Same call, same request, same wire body
    • Why not automatic: The route answers HTTP 200 with a ZERO-BYTE body: POST {auth}/oauth2/delete-client returns nothing from its handler, the vendor declares the endpoint void, and the response carries content-type: application/json with NO content-length header at all. The method ended return res.json(), so it rejected SyntaxError: Unexpected end of JSON input on every successful delete — after the row had already been removed server-side. There was no success path a caller could observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, with the route's 404 not_found, because the client was already gone. The method now reads the body as text, returns on the empty case, and still parses (and still throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same request body, same status codes, same error bodies — which on this route are better-auth's FLAT { error, error_description }, NOT ObjectStack's nested ADR-0112 envelope; no Zod schema and no packages/spec declaration moves, no authorable key and no stored representation is involved, so a raw-HTTP caller is unaffected and objectstack migrate meta has nothing to rewrite. This is registered rather than exempted because the change is NOT compiler-delivered where it matters, and the gap is exact rather than theoretical. The change has two halves and only one of them has a diagnostic. (1) The declared return moves from a ledgered any to void, so a typed caller that read a property off the resolved value now gets error TS2339 — but that read was UNREACHABLE, since the promise never resolved, so the compiler names only code that has never run. (2) The half that DID run on every call — a try/catch wrapped around the delete — compiles identically before and after, with no diagnostic anywhere, while its catch block stops executing. So for the only behaviour that was ever observable, tsc names ZERO sites; and for an untyped JS caller there is no constrained channel at all. That is why the ledger entry is the only notification that reaches an upgrader — the same argument the three sibling entries on this package make (client-delete-result-success, client-meta-reset-result-reset, client-envelope-convergence-analytics-automation). ⚠️ Note the DIRECTION, which is the inverse of the usual break: this does not stop working code from working, it makes a method that could never succeed succeed. The hazard is therefore inverted too — code written to survive a permanent failure is now inert, and any alerting or error budget fed by this method's rejections goes quiet. ⛔ Do not keep the old behaviour behind a flag or a wrapper that re-throws: there is one producer shape, and the rejection was never a contract, it was a parse of an empty string. ⛔ Do not synthesise { deleted: true } either: the 200 carries zero bytes and therefore zero information, and "it was already gone" is distinguished on the ERROR channel — a client that is not there answers 404 { error: 'not_found' }, which ObjectStackClient.fetch raises as a throw before the body reader runs — so a synthesised success value would be a shape the wire never sends and strictly less informative than the 404 the caller already receives. ADR-0087 D3.
    • Done when: ⚠️ The real work is behavioural and NOTHING will report it: every try/catch wrapped around client.oauth.applications.delete() has to be re-read one by one, because it compiles identically before and after while its catch block goes from running on every successful delete to running only on a real failure. Anything that block did — treating the delete as failed, retrying it (the retry answered 404 not_found, which may itself have been swallowed), skipping post-delete cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete to a user as failed — is now on the other branch, and the cleanup paths that were skipped run for the first time. Verify that is what you want rather than assuming it restores prior behaviour. Alerting, error budgets and dashboards fed by SyntaxError rejections from this method drop to zero: that is the fix landing, not an outage. Any test that passed while asserting this call rejects on a successful delete was asserting on the defect and needs rewriting, not renaming. On the type side, no code reads a property off the resolved value; tsc names those sites for a typed caller (TS2339), but every one of them was unreachable, so a clean type-check is NOT evidence that the sweep above was done. An untyped JS caller gets no report at all. Nothing about the request, the route, the status codes or the thrown error shapes changes and no server needs upgrading — the server has always answered this way; only the client stopped mis-reading it. Populations, measured at this landing: in the ObjectStack repo, ZERO production call sites — the only references are the pins that ship with this change (oauth-applications-delete.test.ts, return-type-precision.test.ts); in objectui at the pinned .objectui-sha, ZERO — neither oauth.applications nor delete-client appears anywhere in that tree; objectstack-ai/cloud is NOT MEASURED, and a catch there that swallowed this method's rejection is now dead code that this entry is the only notice of.
  • cloud-subpath-retired — ``@objectstack/spec/cloud — the whole published subpath (packages/spec/src/cloud/, 11 modules, 94 JSON-Schema defs): the cloud control plane's own contracts (environment.zod, environment-package.zod, tenant.zod, developer-portal.zod, marketplace-admin.zod, app-store.zod — 62 defs) and the package & marketplace format (package.zod, package-version.zod, marketplace.zod, package-l10n, template-manifest.zod — 30 defs) → Two answers, by owner. (1) The package & marketplace FORMAT moved unchanged to @objectstack/spec/marketplace (packages/spec/src/marketplace/): rewrite the import path — import { PackageSchema } from '@objectstack/spec/cloud' becomes from '@objectstack/spec/marketplace' — and nothing else; every def, key and JSON Schema is byte-identical under its new $id category (RENAMED_DEFS, 32 entries). EnvironmentType(Schema) — the 7-member taxonomy the discovery fold table is total over — is re-declared in @objectstack/spec/api (api/discovery.zod.ts); the environment-artifact envelope was only ever a re-export and is imported from @objectstack/spec/system. (2) The cloud control plane's contracts have NO open-source replacement: environment.zod and tenant.zod are re-declared in the cloud repo beside their producer, and developer-portal.zod, marketplace-admin.zod, app-store.zod, environment-package.zod are deleted outright — zero consumers in any repo (maintainer ruling 2026-09-07, option A: cloud does not host the four consumer-less files, the move deletes them). Recoverable from git history at d5d8d50db if a declaration is ever wanted again; that is a new card in the cloud repo, not a re-import.
    • Why not automatic: Maintainer direction (2026-09-06, verbatim, untranslated): 「我一直觉得 cloud 的协议应该放在云端,没必要开源」; ruled option B "cut by owner" on 2026-09-07: the control-plane half leaves the open-source spec, the package & marketplace format half stays. The control-plane schemas' producer and every consumer live in the closed cloud repo — the open-source tree read exactly one type from them (EnvironmentType, for the discovery fold table). Leaving them published made the obvious-looking binding of client.environments.* to a camelCase Environment row compile and read undefined at runtime against a snake_case wire (the client SDK's cloud methods carried no return annotation and were typed from any, and @objectstack/spec/cloud declared camelCase rows for a control plane that speaks snake_case); with the declarations gone the mis-binding is structurally impossible rather than warned about in a docblock. No alias and no deprecation window, per the standing 2026-08-27 ruling 「项目在创业阶段,用户也很少,短期不考虑渐进。」. Not losslessly convertible: an import path is TypeScript source, not a metadata document objectstack migrate meta can rewrite.
    • Done when: No code imports anything from @objectstack/spec/cloud — the specifier is not an exports key and every such import fails to resolve (TS2307) after upgrade. Package-format consumers resolve the same symbols from @objectstack/spec/marketplace (pinned by resolved symbol identity in kernel/package-dependency-dual-source.test.ts and system/environment-artifact.test.ts). api/discovery-environment-subset.pin.test.ts still proves DiscoveryEnvironment ⊂ EnvironmentType against the re-declared enum. No metadata document needs editing: the 509 cloud/* authorable-surface baseline keys are discharged by the deletion gate's own proofs — 30 defs carried by declared rename, 62 by whole-def retirement (RETIRED_DEFS_BY_MAJOR[18]) — not by a tombstone an author could hit. ⚠️ Runtime behaviour is deliberately UNCHANGED: os package publish, the marketplace routes and the metadata plugin's artifact ingest parse byte-identically before and after.
  • cluster-driver-dangling-values-removed — kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) - the postgresandnats enum values → the drivers that actually ship - memory (single-process default), redis (@objectstack/service-cluster-redis, the production recommendation), or custom + registerClusterDriver(name, factory) for a self-provided transport. A config naming postgres or nats never worked: pick redis, or register the transport yourself under custom
    • Why not automatic: Maintainer ruling of 2026-08-24 on the cluster driver line-up (option B adopted): single-node is the ObjectOS EE boundary, multi-node is Cloud differentiation, and a DB-first postgres cluster driver is not built absent concrete customer pull. The ruling's principle rider decides this entry: a schema-valid value must not be an unconditional runtime throw. Both removed values were dangling by the same measurement - the only non-test registerClusterDriver() caller is service-cluster-redis, so driver: 'postgres' or driver: 'nats' passed schema validation and then reached defineCluster()'s unconditional Cluster driver "<name>" is not registered throw. It is a SEMANTIC entry rather than a mechanical conversion because the right replacement is a deployment decision (which transport actually backs this cluster), not a rename a codemod could apply; nothing at rest breaks, because a stored config naming either value never survived boot in the first place. The ruling records its own reversal condition: a value returns to the enum only in the release that ships an implementation behind it. No authorable KEY was retired (the useExistingPool field stays, reworded), so nothing lands in RETIRED_KEYS_BY_MAJOR.
    • Done when: No cluster.driver config names postgres or nats; ClusterDriverSchema.parse on the chosen driver value succeeds; a deployment that needed a distributed transport boots on redis (or its custom registration) and defineCluster() no longer throws Cluster driver "<name>" is not registered at startup. TypeScript call sites that typed the removed spellings against ClusterDriver fail tsc on upgrade; the fix is choosing a shipped driver, never widening a local mirror of the enum.
  • connector-action-config-required — The connectorConfig block of every type: 'connector_action' flow node — the BLOCK, and its connectorId and actionId once it is written. The block was optional on the node and both ids were any string inside it, so a node with no block, or with connectorId or actionId empty or only whitespace, parsed. That is the state of a node authored without its configuration, and of a new connector node from the Studio flow designer, which seeds both ids empty. At any depth, including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a connector node added and saved before it is configured), and a flow row already sitting in sys_metadata. Also reached: connectorId, actionId or input written under the node's config instead of the block, where the load-time conversion cannot complete the pair and leaves them there → Declare what the node dispatches, on the node: connectorConfig: { connectorId: 'slack', actionId: 'chat.postMessage', input: { channel: 'C0WINS000', text: 'Done' } } — connectorId the registered connector's name, actionId one of the action keys that connector declares, input optional. Keys written under the node's config move into the block. A node you cannot configure yet is deleted until you can: there is no placeholder connector, and a block with empty ids names nothing to dispatch to
    • Why not automatic: The block is the node's whole contract: the connector_action executor reads nothing else and refuses the node when connectorId or actionId is empty. The build doors checked only the block's shape once it was written, so FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate all admitted a node with no block, or with an empty id, and every run that reached the node then failed at the executor's guard — a guard refusal, never routed to a fault edge, and no rerun could succeed because the config is metadata. The flow parse now refuses what that read refuses, in the walk that reaches every region body, so all three doors answer alike. A whitespace-only id is refused with the empty one: a connector name is a snake_case identifier, so whitespace names nothing a dispatch can reach. ⚠️ No D2 conversion: the platform cannot know the connector or the action the author left out, and no value it could write would dispatch anything. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.connectorConfig for an absent block, at nodes.N.connectorConfig.connectorId or nodes.N.connectorConfig.actionId for an empty or whitespace-only id, or at the region path nodes.N.config.body.nodes.M.connectorConfig…, and objectstack validate prints the same path under flows.K.. For each hit write the block, per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it — that warn line is the locator for a row that exists only in sys_metadata. A connector node carrying a complete block parses, registers and dispatches as before, and input stays optional.
  • connector-error-mapping-retired — connector.errorMapping — the rules / defaultCategory / unmappedBehavior / logUnmapped block and its per-rule keys, on a connector and on a stack connectors[] entry → (removed — no connector engine maps an external error through authored rules.) Retry behaviour is retryConfig, which the outbound fetch applies. No connector-level channel shows an end user a message: an error users must read is surfaced by whatever handles the connector call's failure.
    • Why not automatic: The D2 conversion connector-error-mapping-removed deletes the whole block from every connector, stack entry and stored connector row, with one notice per connector, and the delete is lossless: no provider, dispatcher or materializer ever mapped an external error through the rules, so the eleven nested keys configured nothing. The judgment is about what the rules were written to achieve. A rule marking an upstream code retryable never changed a retry — if that retry matters, it belongs in retryConfig. A rule with a userMessage never showed that message to anyone, although the spelling matches the live API-error channel and read as a user-facing refusal; if users need that text, whatever handles the failed call has to surface it. unmappedBehavior and logUnmapped suppressed or logged nothing. Which of these intents still matters is known only to the connector's author.
    • Done when: No connector and no stack connector entry carries errorMapping; the parse refuses it, and no code imports ErrorMappingConfig, ErrorMappingRule or ConnectorErrorCategory. Calls through each connector fail and retry exactly as they did before the upgrade. For every rule whose intent still matters: a retry the author wanted is expressed in retryConfig and observed on a failing upstream, and a message the author wanted users to read is shown to them, by the caller that handles the failure, when the upstream fails.
  • connector-provider-context-connection-timeout-ms-retired — ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed to every ConnectorProviderFactory (integration/connector-provider.ts) → requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, the provider's own providerConfig, where the provider owns the vocabulary
    • Why not automatic: ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry carries the half a conversion cannot reach. The key was placed on this context by the round that made the connector resilience policy live, explicitly as a CARRY — handed over so that a custom provider on a transport able to separate the phases could honour it. Measured before removal, none did, and the carry itself was the last thing keeping the key alive in argument: the built-in rest and openapi factories read ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors echoes, and connectorFetchOptions — the one mapping from authored policy onto the platform's outbound fetch — was never handed it. Being handed a value is not honouring it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and it leaves with the key rather than outliving it as an orphan a factory could still read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so the chain has no seam to rewrite — the removal reaches a factory author as a tsc error and as this entry, never as a mechanical edit. The declaration cannot be made honest by implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ADR-0087, ADR-0097.
    • Done when: No ConnectorProviderFactory reads ctx.connectionTimeoutMs; the member does not exist on ConnectorProviderContext and reading it fails to compile. A factory that genuinely needs a connect-phase bound declares it in its own providerConfig and applies it itself, on a transport that can observe the connect phase — it does not receive one from the host. Behaviour is unchanged for every shipped provider, because none applied the value: a connector that authored connectionTimeoutMs made exactly the same calls with exactly the same deadlines before and after. What does change is observable and intended: the def served by GET /connectors no longer echoes a connect deadline nobody keeps, and requestTimeoutMs — which resilientFetch applies as each attempt deadline — is the only timeout on the surface. The sibling members retryConfig and requestTimeoutMs deliberately do NOT move, and a sweep that removed either has over-applied this entry: both resolve to real reads at the fetch site.
  • connector-resilience-keys-retired — connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — on a connector and on a stack connectors[] entry → (removed — nothing replaces the probe, the breaker or an authored status.) Participation is enabled (and provider on a declarative instance); whether a registered connector can be dispatched is the computed state (ready / degraded) on GET /api/v1/automation/connectors; a webhook that is actually delivered is declared in the top-level webhooks: collection; probes and circuit breaking belong in the connector provider or an upstream gateway.
    • Why not automatic: The D2 conversion connector-resilience-keys-removed deletes health, status and webhooks from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no loop ever polled a connector endpoint, counted failures or tripped a breaker, no code read an authored status, and a webhook nested in a connector was never registered, materialized or delivered. Three judgements remain. First, a probe or breaker the author believed was protecting a flaky upstream never was — if that protection matters, it has to be built where calls are made (the connector provider) or in front of the upstream (a gateway). Second, status values like active or error gated nothing; an author who used status to switch a connector off needs enabled: false on the declarative entry instead. Third, the nested webhooks are STRIPPED, not moved: redeclaring one in the top-level webhooks: collection STARTS deliveries that never happened before, so which of them should exist is the author's call — and their events (sync.completed, auth.expired and the rest) and signatureAlgorithm have no counterpart there. The chain: in this same protocol step, connector-health-and-trigger-durations-unit-in-key no longer renames health.circuitBreaker.monitoringWindow to monitoringWindowMs — the whole block that key lived in is removed, so an author holding either spelling ends with no key at all. That conversion's other half, triggers[].interval to intervalSeconds, was absorbed the same way by the removal of the whole triggers array (connector-triggers-removed), so the rename itself is no longer in the step.
    • Done when: No connector and no stack connector entry carries health, status or webhooks; the parse refuses each with its prescription (a stored status: 'inactive' default is accepted and stripped as inert residue), and no code imports ConnectorHealth, HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent or WebhookSignatureAlgorithm. Every connector dispatches exactly as it did before the upgrade. Each declarative connector instance the author meant to be switched off carries enabled: false and is observed absent from GET /api/v1/automation/connectors; each nested webhook that is still wanted is declared in the top-level webhooks: collection and observed delivering; and each probe or breaker the author relied on is provided by the connector provider or a gateway and observed tripping against a failing upstream.
  • connector-sync-keys-retired — connector.syncConfig (strategy / direction / realtimeSync / timestampField / conflictResolution / batchSize / deleteMode / filters) and connector.fieldMappings[] (source / target / defaultValue / dataType / required / syncMode), on a connector and on a stack connectors[] entry → A sync is defined on its TARGET: a mapping (targetObject, fieldMapping, mode, upsertKey) whose connectorSource names the rest or openapi connector instance it pulls from (connector), the action that reads the records (action, with a fixed input and a recordsPath) and, for a timestamp-incremental pull, a watermark (field on the record, param on the request); a job sets the cadence. The pull executor reads the binding when a job drives it — a job whose pull: { mapping } names the mapping, on the job's schedule; the binding alone moves no rows.
    • Why not automatic: The D2 conversion connector-sync-keys-removed deletes syncConfig and fieldMappings from every connector, stack entry and stored connector row, one notice per key, and the delete is lossless: no engine ever ran a connector-attached sync or moved a value through a connector field mapping, so nothing the upgrade removes was ever happening. Three judgements remain. First, any part of the deployment designed around a connector sync running has never been running, so the author decides which syncs should now exist as target-side mappings; the conversion STRIPS the keys and never writes a mapping, because a mapping that is pulled STARTS writes into a table that never received them — its target object, match key and cadence are the author's. Second, the retired block named a direction, a conflict policy and a delete policy that no runtime applied — every delete is a hard delete, and latest_wins resolved nothing — and the pull that replaces it is one-way (external to local) and writes only through the mapping's mode and upsertKey: an author who relied on export, bidirectional, soft_delete or a conflict policy decides what to do without them. Third, a connector field map moved values nowhere; carrying its source → target pairs into mapping.fieldMapping makes them real for the first time, including any defaultValue, which the import mapping spells as a constant transform, and required, which the target field declares.
    • Done when: No connector and no stack connector entry carries syncConfig or fieldMappings; the parse refuses either key with its prescription, and no code imports DataSyncConfig, SyncStrategy, ConnectorConflictResolution or ConnectorFieldMapping or their schemas. Every connector registers and dispatches its actions exactly as it did before the upgrade. Each sync the author still wants is a mapping whose connectorSource names a rest or openapi connector instance, with a job whose pull names that mapping chosen for its cadence.
  • connector-triggers-retired — connector.triggers — the ConnectorTrigger array (key / label / description / type / intervalSeconds, and the interval spelling it was renamed from), on a connector and on a stack connectors[] entry → (removed — nothing replaces a connector trigger.) Start the work from a flow that calls the connector's action in a connector_action node: an external event starts an api flow that the event's sender calls, and a scheduled pull is a schedule flow.
    • Why not automatic: The D2 conversion connector-triggers-removed deletes triggers from every connector, stack entry and stored connector row, one notice per connector, and the delete is lossless: the automation engine registered a connector's actions only, no polling loop read an interval, no receiver was driven by a webhook trigger, and no provider derived one — so a declared trigger never started a flow, before or after the upgrade. Three judgements remain. First, any part of the deployment designed around a connector trigger firing has never been running, so the author decides which of those triggers should now exist as flows: a polling trigger becomes a schedule flow whose connector_action node calls the connector's read action, and a webhook trigger becomes an api flow that the external sender calls. The conversion STRIPS the array and never writes a flow, because a flow that runs STARTS work that never happened before — its cadence, its action and what it does with the result are the author's. Second, a polling cadence is in SECONDS: the key was renamed from interval to intervalSeconds earlier in this same protocol step because the bare interval means milliseconds elsewhere in this spec, so a trigger written interval: 60000 for one minute asked for once every sixteen hours or so — carry the intended cadence, not the stored number, into the schedule. Third, turning a webhook trigger into an api flow opens an inbound endpoint that never existed before (the trigger declared no receiver and no verification), and the platform refuses an api flow with no per-flow secret and verifies a signature on every call — so whether the external sender can sign its calls decides whether that flow can receive them directly. The chain: in this same protocol step, connector-health-and-trigger-durations-unit-in-key no longer renames triggers[].interval — the whole array that key lived in is removed, so an author holding either spelling ends with no key at all.
    • Done when: No connector and no stack connector entry carries triggers in any spelling; the parse refuses the key with its prescription, and no code imports ConnectorTrigger or ConnectorTriggerSchema. Every connector registers and dispatches its actions exactly as it did before the upgrade. Each connector trigger the author still wants is a flow that is observed running: a scheduled pull as a schedule flow whose connector_action node calls the connector's action at the intended cadence in seconds, and an external event as an api flow observed starting when the sender calls it.
  • cube-join-sql-and-relationship-retired — analyticsCubes[].joins.<alias>.sql / analyticsCubes[].joins.<alias>.relationship — the authored ON clause and the declared cardinality on a cube join → analyticsCubes[].joins..name alone. The ON clause is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality: NativeSQLStrategy emits the LEFT JOIN and its ON from the dotted member path, and ObjectQLStrategy lowers the same alias to a relationship traversal with no ON clause at all. The record KEY is the foreign-key FIELD on the base object, never a second spelling of the object the join reaches.
    • Why not automatic: The KEYS convert mechanically and do: the paired D2 conversion cube-join-sql-and-relationship-removed deletes both from every join, which is lossless because neither ever had an effect to lose, and names the cube in each notice. What does NOT convert is the INTENT. sql was REQUIRED and documented as the ON clause, and no reader ever consulted it: an authored condition was REPLACED by the synthesised foreign-key equality and the aggregate came back under a 200, joined on something the author had not asked for. relationship carried a .default('many_to_one') that nothing dispatched on, so one_to_many parsed, changed no SQL, and kept the many-to-one arithmetic. Deleting the keys restores honesty but does not give an author who wanted a non-FK join the thing they wanted, and it does not re-check the numbers the replaced join already produced. That is why this entry is a TODO addressed to them rather than a claim that the strip finished the job. A custom join condition is a capability card with its injection / allow-list boundary decided first, which the ruling deferred deliberately.
    • Done when: Delete sql and relationship from every entry of every cube joins map; keep name. The paired D2 conversion cube-join-sql-and-relationship-removed performs that same strip mechanically wherever the chain is replayed — including over metadata already at rest, so a deployed artifact keeps booting while you do. Then check three things. (1) Did any deleted sql express something OTHER than the foreign-key equality between the two objects — a filtered join, a non-key column, a literal predicate? If so, the query you were getting was already the FK-equality answer and not the one you wrote, so re-read the numbers that join produced before assuming this change moved them; the fix is to model the relationship on the object, or to open a capability request for an authorable join condition. (2) Did any deleted relationship say anything but many_to_one? If so, the aggregate was already computed as many-to-one and still is — this change alters no result, it only stops the declaration from claiming otherwise. (3) Is each join KEYED by a foreign-key field of the cube's own base object? The key is the column the derived ON clause reads, so a join keyed after the object it REACHES never resolved at all. Nothing else regresses: joins.<alias>.name is unchanged, and it is what both the joined table and the per-object RLS/tenant read scope are resolved from.
  • cube-member-inner-name-retired — analyticsCubes[].measures.<metric>.name / analyticsCubes[].dimensions.<dimension>.name — the inner name a cube member used to require → The record key. measures and dimensions are records, and the key a member is declared under IS its name: the analytics API publishes it as <cube>.<key> and a query names it that way. To rename a member, rename its key.
    • Why not automatic: The D2 conversion cube-member-inner-name-removed deletes the inner name from every metric and dimension of every cube, and the delete is lossless in behaviour: every consumer — discovery, both query strategies, the in-memory driver — resolves a member by its record key, so the inner value was never read. Where it EQUALED its key there is nothing left to decide. Where it DISAGREED, the key was already the name every query, dashboard and report used, and the inner value was a spelling nothing read; the conversion notice prints both. Only the author can say whether the disagreeing spelling was the one they meant — in which case the member must be re-keyed, and every consumer that names <cube>.<old key> changes with it — or a stale copy to drop.
    • Done when: No metric or dimension of any cube carries name; the parse refuses it with the prescription. For every conversion notice whose from shows a name that differed from its record key, the author has either kept the key (nothing else changes) or re-keyed the member to the intended name and updated every query, dashboard and report that names <cube>.<old key>. GET /api/v1/analytics/meta lists each member as <cube>.<key> exactly as before the upgrade.
  • cube-member-sql-expression-retired — analyticsCubes[].measures.<metric>.sql and analyticsCubes[].dimensions.<dimension>.sql (data.MetricSchema.sql / data.DimensionSchema.sql) authored as a SQL expression — a CASE expression, an aggregate or a ratio of aggregates, a quoted or $-prefixed spelling, or any other value that is not a column reference → a column reference: a field of the cube's object (amount), a relationship path ending in one (account.amount), or '*' for a count. A derived value moves to an ADR-0021 dataset over the same object: a conditional count or sum is a dataset measure with its own structured filter ({ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }), and a ratio, sum, difference or product of measures is derived: { op, of: [...] } over measures named in the same dataset ({ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }). A dimension that bucketed a column with a CASE expression has no expression form in either layer: group by the column itself, or keep the bucket as a field of the object and name that field
    • Why not automatic: Maintainer ruling D (2026-09-30), from the analytics field-level read gate: a member whose sql is an expression names no single field, so no platform check can judge which fields it reads, and the analytics strategies never agreed on it — the raw-SQL path emitted it verbatim and the ObjectQL path refused it. ADR-0021 already set the direction for the author surface ("zero raw SQL / zero raw expressions"); it governed the dataset layer and left the cube members it compiles to open, which is the gap this closes. The dataset form is the declared home of a derived value because every field it reads is named: a measure filter names its fields, and a derived measure references other measures by name only. There is no D2 conversion: the rewrite moves a member to a different metadata type and cannot be derived from the expression text in general, so only the author can say which dataset measures express what the expression meant. A ratio also changes SCALE on the way: a derived ratio is a 0–1 fraction, while an expression that multiplied by 100 returned percentage points — pair the ratio with a % numeral pattern (the server marks a ratio column's percent scale as a fraction) and re-check any consumer that read the old number raw. ADR-0021 / ADR-0049 / ADR-0087
    • Done when: Every analytics cube parses: CubeSchema, the analytics_cube write door and defineStack refuse an expression member at its sql with a prescription that names the dataset form, so the sweep is mechanical — parse each cube, and each refusal is one member to move. For each moved measure, a dataset over the same object declares it, and a query over a fixture where the condition excludes rows returns the same figure the expression returned (a ratio: the same value divided by 100 when the expression returned percentage points). Every dashboard, report or saved query that named the cube member now names the dataset measure. A cube member that aggregates a column parses byte-identically to before.
  • cube-metric-expression-types-retired — analyticsCubes[].measures.<metric>.type (data.AggregationMetricType) authored as number, string or boolean — the custom-SQL-expression metric types → the aggregate the measure means: sum, avg, min or max over the column, count (over '*' for a row count, or over a column for its non-null values), or count_distinct. A value computed per row becomes a field of the object (a stored or formula field) that the measure aggregates; a ratio or other value derived from measures is derived: { op, of: [...] } on an ADR-0021 dataset
    • Why not automatic: The three types existed to mark a measure whose sql was the whole computation — a ratio, a CASE, a window function — and named only what it returned. Since cube-member-sql-expression-retired a member's sql is a column reference, so the types had nothing left to declare: measured before this retirement, the raw-SQL strategy emitted the referenced column unaggregated (a bare column in a grouped statement, by SQL's own rules an error on PostgreSQL and an arbitrary row's value on SQLite) and the ObjectQL strategy refused the measure. There is no D2 conversion: the column alone does not say which aggregate the author wanted — a number over amount may have meant its sum, its average or its largest value — so only the author can choose, and a measure whose old expression computed something per row needs that value stored on the object before any aggregate can read it. Nothing is rewritten or dropped at rest: a stored or built cube that still carries one of the three is refused, with the prescription, at the boot and write doors, and a cube that reaches the analytics service without meeting the parse is refused at query time with the same text. ADR-0049 / ADR-0087
    • Done when: Every analytics cube parses: CubeSchema, the analytics_cube write door and defineStack refuse a measure typed number, string or boolean at its type with a prescription naming the six aggregates, so the sweep is mechanical — parse each cube, and each refusal is one measure to retype. For each retyped measure, a query over a fixture with more than one row per group returns the aggregate the author chose, and every dashboard, report or saved query that read the measure is checked against the number it now returns. A measure typed with one of the six aggregates parses byte-identically to before.
  • cube-metric-filters-retired — analyticsCubes[].measures.<metric>.filters — the per-metric raw-SQL filter list → One of the two filters that ARE applied: a where condition at query time, or an ADR-0021 dataset measure with a structured filter. (A third channel — folding the condition into the metric's own sql expression — left with cube-member-sql-expression-retired: a member's sql is a column reference.)
    • Why not automatic: The D2 conversion metric-filters-removed deletes filters from every cube metric, and the delete is lossless in the narrow sense: neither SQL strategy ever read the key, so a metric authored with filters: [{ sql: "stage = 'closed_won'" }] already returned the UNFILTERED aggregate under the author's metric name, and still does. That is exactly why the strip does not finish the job. The author wrote a condition because they wanted a filtered number; every dashboard, report and export reading that metric has been showing a larger one. Only the author can say which of the two live mechanisms expresses the condition they meant — a query-time where changes every query, a dataset measure moves the metric to the governed layer — and whether numbers already published from the unfiltered metric need to be revisited.
    • Done when: No cube metric carries filters; the parse refuses the key by name. For each metric that carried one, the author has either re-expressed the condition through one of the two live mechanisms or decided the unfiltered aggregate is what they want — and renamed the metric if its name promised the filter. With the condition re-expressed, a query over a fixture where the condition excludes rows returns the filtered aggregate (strictly smaller for a positive sum over excluded rows), not the unfiltered one.
  • cube-refresh-key-retired — analyticsCubes[].refreshKey (every, sql) — a cube's declared refresh cadence and data-change probe → Nothing: delete the key. No analytics result is cached, so every query against a cube is computed when it is asked. A refresh cadence is declared again when a result cache exists.
    • Why not automatic: The D2 conversion cube-refresh-key-removed deletes refreshKey from every cube, and the delete is lossless: nothing read every or sql, and no analytics result was ever cached for them to refresh, so no query answers differently. What the conversion cannot check is whether anything the author built assumed that cube results were cached or refreshed on a schedule. They never were.
    • Done when: No cube carries refreshKey, and the parse refuses one with the prescription. Every analytics query answers as it did before the upgrade. Nothing the author maintains relies on cube results being cached or refreshed on a schedule.
  • currency-config-precision-retired — object.fields.*.currencyConfig.precision — the decimal-places key of a currency field's configuration, and its never-accepted decimals/scale spellings → (removed — nothing replaces it.) A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), derived from the currency itself and declared nowhere. Delete the key. Do not move the number to the field-level precision: that key is the amount's total digit count, not its decimal places, and it is unchanged.
    • Why not automatic: The D2 conversion currency-config-precision-removed deletes the key from every field's currencyConfig on objects and object extensions — in author sources, in stored object rows and in built artifacts, which can carry a 2 the old schema wrote into parse output without anyone authoring it — and the delete is lossless: no renderer or runtime ever read the key. Every display face derives the width from the currency. Two judgments remain, and neither is a rewrite. First, a width that never applied: the old contradiction check judged an authored value only on a fixed field whose code has a known ISO 4217 minor unit, so on a dynamic field, and on a fixed field whose code has none (a crypto or custom code), an author could declare a width other than the one the field displays — and read amounts as if it applied. Whether the displayed width is acceptable for that field is the author's call. Second, code the chain cannot reach: a plugin, integration or export of your own that read currencyConfig.precision from served object metadata now finds no key, and must derive the width from the field's currency the way the platform's renderers always did.
    • Done when: No field's currencyConfig carries precision, decimals or scale — in sources, in stored object rows or in built artifacts; the parse refuses each by name with the prescription, and a stored row or artifact written before the upgrade loads without a refusal over it. No code of your own reads currencyConfig.precision; where it needed a width, it derives one from the field's currency. Every currency field renders its amounts exactly as before the upgrade, because the key never changed a rendered amount. os migrate meta --stored --apply rewrites stored rows so the per-row notice stops. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
  • dashboard-header-modal-target-page-only — dashboard header.actions[]entries withactionType: 'modal'— anactionUrlnaming a defined action, a bare object name, or the prefix form (create/new_/add_/edit_/update_ + object). The keys themselves are unchanged and still parse; what changed is what the string RESOLVES to → a declared page name (stack.pages, by name) — a modal target names a PAGE, only. To open an object's create/edit form from a dashboard header, use actionType: 'form' with an <object>.<view> form-view target (actionType accepts the full action-type enum, so that shape reaches this surface too)
    • Why not automatic: Maintainer ruling A on modal targets (2026-08-09): a type: 'modal' string target names a PAGE, only — the spec TSDoc, the published docs and defineStack's cross-reference walk already agreed, and the renderer's page-then-object leniency (self-labelled Back-compat) was retired rather than codified. One objectui change deleted the object fallback in the shared useActionModal; a second deleted DashboardView's own second copy of the prefix convention (which had no page resolution at all), after enumerating both repos' corpora and finding zero producers of the prefix form. The os validate lint rule (validateDashboardActionRefs) then still pointed the other way: it accepted the retired shapes — blessing buttons that dispatch to a named refusal at runtime — and ERRORED on a page-named target, the one shape the runtime serves. The rule now resolves a modal target against declared pages, only. The ruling explicitly declined the middle shape (keep the prefix, reject bare object names): create_opportunity names the page create_opportunity, or it names nothing.
    • Done when: Every dashboard header.actions[] entry with actionType: 'modal' has an actionUrl naming a declared page (os validate passes the dashboard-action-refs rule); header buttons meant to open an object's form declare actionType: 'form' with an <object>.<view> target instead. Clicking each converted button opens the intended page or form rather than a refusal dialog.
  • dashboard-refresh-interval-unit-in-key — dashboard.refreshInterval — the auto-refresh cadence of a dashboard → refreshIntervalSeconds — the same cadence, in seconds, with the unit in the key name. The old rename hints (refresh, autoRefresh, pollInterval) now point at it.
    • Why not automatic: The D2 conversion dashboard-refresh-interval-to-refresh-interval-seconds renames refreshInterval to refreshIntervalSeconds in the dashboards collection and on stored dashboard rows, keeping the value, and the rename is lossless: the key always meant seconds. Two judgments remain. First, the unit: nothing in the old name said seconds, and three other spellings authors reached for named no unit either, so a value written in milliseconds — refreshInterval: 30000 meant as thirty seconds — asked for a refresh about every eight hours, and the rename keeps 30000. Second, the reader: the dashboard renderer ships in the separately released console, and when this rename landed it still read the old key, so a console that has not yet moved sees no cadence and starts no timer. A dashboard that stops refreshing after the upgrade is that lag, not a wrong value — which only a look at the running console can tell apart.
    • Done when: No dashboard carries refreshInterval; the parse refuses it with the rename. Every refreshIntervalSeconds value is the cadence the author intends in seconds — a dashboard meant to refresh every thirty seconds reads refreshIntervalSeconds: 30. In the console the deployment runs, an open dashboard re-queries its widgets at that cadence; where it does not, the console build predates the renderer's move to the new key, and the author has recorded that until the console is upgraded.
  • dashboard-widget-chart-config-structure-refused — ``dashboard.widgets[].chartConfig.type/.xAxis/.yAxis/.series — the four keys that said which chart family to draw, which series exist and which column each one reads on a DATASET-BOUND widget (REMOVED) → the widget’s own type and its ADR-0021 dataset selection. chartConfig.type becomes the widget’s type (the chart family has always been the widget’s — the dashboard renderer maps the widget type to the chart family and never read the chart config’s). chartConfig.xAxis.field becomes an entry in the widget’s dimensions: the dataset dimension the category axis plots. Each chartConfig.yAxis[].field becomes an entry in the widget’s values: the dataset measure that axis plots, one entry per mark, and a second axis is a second measure rather than a second axis declaration. Each chartConfig.series[].name is the same measure name, so a series list that matched values needs nothing and one that did not was already being ignored. What has NO replacement, and is the reason this is a TODO rather than a rewrite: the PRESENTATION those objects carried alongside the binding — ChartAxis.title / format / min / max / stepSize / showGridLines / position / logarithmic, and ChartSeries.label / color / type / yAxis / stack / dashArray / opacity. The dataset’s own dimension and measure declarations are what label and format a dataset-bound chart now; colors on the same chart config remains the palette channel, and a per-series mark type (the combo chart a widget could author through series[].type) has no authoring channel on this face at all.
    • Why not automatic: Maintainer ruling of 2026-09-12 on the dataset-bound chart config, taking options C+D together: the protocol states the ownership split AND refuses the structural keys by name, because stating it without refusing them leaves the declared-but-inert shape ADR-0049 exists to end, and refusing them without stating it leaves an author with no reason. The defect being closed is not cosmetic: an authored yAxis[].field was a LIVE MEMBERSHIP CHANNEL — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. ⛔ Not mechanically convertible: the D2 conversion can delete the keys from a stored widget, but moving what they MEANT into the dataset selection needs facts the item does not carry — whether the dataset declares a dimension by that name, whether the measure is in the dataset at all, and whether the author wanted the axis they wrote or the one the selection derives. An authored field naming a column outside the selection is exactly the case where a walker guessing would produce a different chart rather than a refused one. The keys are NOT retired from the chart config itself: ReportChartSchema keeps its own xAxis/yAxis (narrowed to its bound dataset’s dimension and measure names), and the react <ObjectChart data={…}> tier keeps all four, because an inline-data chart has no dataset to derive structure from and the author’s axes are the only ones there are.
    • Done when: Measured against the shipped schema, not restated from the card. (1) No dashboard widget carries chartConfig.type, .xAxis, .yAxis or .series: the D2 conversion dashboard-widget-chart-config-structure-removed strips them from authored sources on a chain replay and os migrate meta --stored --apply covers rows already at rest, and a value that reaches a parse is refused at that key’s own path with the prescription naming the dataset selection. (2) For every widget that carried one, the chart it draws after the migration is the chart the author meant: the family is the widget’s type, the category axis plots the dimension named in dimensions, and there is one mark per measure named in values — verified by rendering the dashboard, not by reading the metadata, because the pre-migration chart may have been plotting a column the selection never named. (3) A widget whose authored axes AGREED with its selection renders identically before and after, and that is the expected case; a widget that renders differently was relying on the membership channel this removes and is the case the ruling was made about. (4) Axis titles, number formats, axis bounds, grid lines and per-series labels/colours/mark types are gone from the widget and are NOT expected back: a dataset-bound chart takes them from the dataset’s dimension and measure declarations. A combo chart that was authored through series[].type on a dataset-bound widget has no authoring channel on this face after the change — that capability loss is ruled, not incidental, and an inline-data react <ObjectChart> is where a per-series mark type is still authored.
  • dashboard-widget-dimensionless-multi-measure-refused — dashboard widget measure arity WITHOUT a dimension — dashboard.widgets[].values (DashboardWidgetSchema.values) on a widget whose dimensionsis absent or empty and whosetypeis one of the seven chart types that declare no rendering for several measures:pie/donut/funnel/scatter/radar/treemap/sankey`` → Pick a visual that renders several measures, or split the widget. With no dimension, type: 'table' renders a row of measures and a bar-family type (bar / column / horizontal-bar) renders one bar per measure; both keep the unbounded values they have always had. The full set of types that render several measures on a dimensionless widget is the exported constant DASHBOARD_WIDGET_MULTI_MEASURE_TYPES — at this release table, pivot, bar, column, horizontal-bar, line, area and combo — and the refusal prints it from that constant. Or keep the type and give each measure its OWN widget: a new id, the same dataset, that one measure in values, and its own layout if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a dimensionless two-measure pie meant a table, a bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen.
    • Why not automatic: Maintainer ruling D, on objectui's finding that a widget silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议」 — the protocol is fixed where it admits measures a widget type cannot render. Its first application bounded the metric FAMILY to one measure (dashboard-widget-metric-family-multi-measure-refused); this entry applies the same principle to the chart types. Measured in objectui by the dev who delivered the multi-measure renderings for table / pivot and the bar, line, area and combo families: the other seven ChartTypeSchema members, given no dimension and two or more measures, render values[0] and drop the rest — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because values is z.array(z.string()).min(1) with no upper bound outside the metric family. That is the declared≠delivered shape ADR-0049 exists to end. The census before the change found zero authored dimensionless multi-measure widgets of any type in the platform's examples or in objectui's example apps, so this ships at once with no deprecation window: there is no window in which a queried-and-discarded measure does anything. Relaxing later is free and needs no second migration — if one of the seven gains a declared multi-measure rendering (a radar of measures, a funnel of measure stages) it joins the constant, while leaving the key unbounded costs an author a widget that silently drops what they declared. ⛔ This change does not invent those renderings.
    • Done when: ⚠️ WHICH DOOR: the refusal is the spec's, and it reaches every door that parses the spec schema — measured on defineStack, which throws naming the widget; on os validate, which loads the configuration through defineStack and fails there with that same issue; on the stack schema and the dashboard metadata-type schema; and on the metadata save path, where an ACTIVE save and a DRAFT save of such a dashboard both answer 422 INVALID_METADATA at widgets[N].values and persist nothing. It is NOT refused by objectui's client-side authoring door until that door chains the new export: @object-ui/types builds its DashboardWidgetSchema from a .shape spread of the spec's, which carries the FIELDS and drops every object-level check, so its editor keeps accepting a dimensionless two-measure pie and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a SemanticMigration is static prose emitted once per hop, with no per-document interpolation and no filtering by whether the stack carries the shape, so os migrate meta prints THIS paragraph, not a list of your widgets. The refusal is what names them, per widget, on the re-parse — drive the fix off os validate, not off the migrate output. WHAT IS REFUSED, exactly: ONE custom issue at widgets[N].values, naming the widget's id, the number of measures and the authored type, when dimensions is absent or an empty array, values carries two or more measures, and type is pie, donut, funnel, scatter, radar, treemap or sankey. The check is the exported checkDashboardWidgetChartMeasureArity (exported as checkDashboardWidgetDimensionlessMeasureArity until dashboard-widget-single-series-multi-measure-refused gave it a second arm and renamed it), and the set it reads is the exported DASHBOARD_WIDGET_MULTI_MEASURE_TYPES — one list, which the check, the refusal text and the values doc string all read. WHAT IS NOT, so this is not read as complete: the same seven types WITH a dimension are outside THIS entry — scatter and radar keep accepting several measures with a dimension, and pie / donut / funnel / treemap / sankey are refused with a dimension too, by dashboard-widget-single-series-multi-measure-refused; one measure parses on every type; every type in DASHBOARD_WIDGET_MULTI_MEASURE_TYPES keeps accepting any number of measures with no dimension; the metric family (metric / kpi / gauge / solid-gauge / bullet, and a widget that declares no type, which resolves to metric) keeps its OWN refusal, unchanged and still ONE issue — that check refuses a second measure at any dimensionality, and this one steps aside for the family rather than adding a second issue on the same values; an EMPTY values keeps the field's own too_small; a type outside ChartTypeSchema reports the TYPE refusal alone (zod treats that invalid_value as aborting and skips object-level checks), and called directly on a wider type enum the export judges only the types the spec declares; and whether each measure EXISTS in the bound dataset is still unreachable from this schema. VERIFY by re-parsing each dashboard: a dashboard that had one dimensionless two-measure pie should end with a table or bar-family widget carrying both measures, or with two widgets of one measure each — check the rendered grid afterwards, because the second measure is a number the dashboard was ALREADY paying to compute and had never shown.
  • dashboard-widget-metric-family-multi-measure-refused — dashboard widget measure arity — dashboard.widgets[].values (DashboardWidgetSchema.values) on a widget whose type is one of the metric FAMILY (metric/kpi/gauge/solid-gauge/bullet), INCLUDING a widget that declares no typeat all and so resolves to themetric default → ONE measure per tile. Keep the measure the tile is actually for — in practice values[0], which is the only one that has ever rendered — and give each of the others its OWN widget: a new id, the same dataset, that one measure in values, and its own layout if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: N tiles need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. If several numbers in ONE widget is what was meant, that is a different visual and the arity rule is not in its way: type: 'table' renders a row of measures, and the chart families (bar / line / area / combo) render one mark per measure — all of them keep the unbounded values they have always had.
    • Why not automatic: Maintainer ruling D of 2026-09-12, on objectui's finding that a metric tile silently drops every measure after the first, applying the maintainer's standing rule 「协议不正确的应该先修改协议。」 — judge the protocol wrong rather than invent display semantics for values[1..]. Measured in objectui's first report of the defect: values was z.array(z.string()).min(1) with NO upper bound on every widget type, so a metric tile could declare three measures; the dataset query selected and computed all three, and the tile rendered values[0]. The other two were queried and dropped on the floor — the declared≠delivered shape ADR-0049 exists to end, kept alive by a runtime warning rather than closed. An objectui fix (merged) added the declared sub-caption, and objectui's interim half made the tile SAY that the extra measures are not rendered: that makes the tile honest about dropping them, it does not make the document legal. A metric tile answers ONE number — that is what the family means on every mainstream dashboard product, and ChartTypeSchema groups these five under "Performance (single value)" in its own words. Several numbers is a DIFFERENT visual, not a variant of this one, so the repair is an accept-set narrowing and not a renderer feature. ⛔ NOT the other arm (the wish, from a downstream application's manager dashboard, for several numbers on one tile): under this ruling that is a request for a different widget type, and it stays reachable through table / the chart families, which this narrowing does not touch. Ships at once, no deprecation window: there is no window in which a queried-and-discarded measure does anything. Widening later (a real gauge renderer that draws a target band, say) costs an author nothing and needs no second migration — a narrowing that is later relaxed is free, while leaving the key unbounded costs them a tile that silently drops what they declared.
    • Done when: ⚠️ WHICH DOOR: this refusal is the PUBLISH door's, not the editor's. Every stored dashboard carrying more than one measure on a metric-family widget is refused the next time it is parsed THROUGH @objectstack/spec — os build / os lint, the metadata publish path, and any server-side door that parses the spec schema — with ONE custom issue at widgets[N].values naming the widget's id, the number of measures it declared, and the authored type. It is NOT refused by objectui's client-side authoring door: @object-ui/types builds its own DashboardWidgetSchema from specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict(), and a .shape spread carries the FIELDS while dropping every object-level check, so until that package imports and chains checkDashboardWidgetMetricMeasureArity the dashboard EDITOR keeps accepting three measures on a metric and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a SemanticMigration is static prose emitted once per hop — applyMetaMigrations maps step.semantic straight onto the result with no per-document interpolation and no filtering by whether the stack even carries the shape — so os migrate meta prints THIS paragraph, not a list of your dropped measures. The refusal is what names them, per widget, on the re-parse. Drive the fix off os build, not off the migrate output. WHAT IS REFUSED, exactly: two or more values members on metric, kpi, gauge, solid-gauge or bullet, and on a widget that declares no type (it resolves to metric, and the message says so rather than claiming you wrote it). WHAT IS NOT, so this is not read as complete: a single-measure tile of any of those five types parses byte-identically to before; all fifteen OTHER members of ChartTypeSchema — bar, horizontal-bar, column, line, area, pie, donut, funnel, scatter, treemap, sankey, combo, radar, table, pivot — keep accepting three measures, unmoved; an EMPTY values keeps the field's own too_small from .min(1) and gains no second issue ("exactly one" is the conjunction of that lower bound and this upper one, so a mirror re-attaching this export onto a shape without .min(1) gets the upper bound only); a widget whose type is outside ChartTypeSchema reports the TYPE refusal ALONE (zod treats that invalid_value as aborting and skips object-level checks), so the arity refusal arrives on the next parse and the two are never seen together; and whether the surviving measure EXISTS in the bound dataset is still unreachable from this schema — a tile naming one measure nobody declared parses exactly as it did before. Nothing new is broken for consumers that DERIVE this schema: .omit()/.pick()/.partial() already threw on it before this change, because it already carried checkDashboardWidgetStageOrder; .extend() is unaffected — except that zod 4.4.3 refuses an .extend() which OVERWRITES a key on a refined object ("Cannot overwrite keys on object schemas containing refinements. Use .safeExtend() instead"), which was already true here and is why a per-type union arm was not the spelling chosen. VERIFY by re-parsing each dashboard and reading the widget count: a dashboard that had one three-measure metric tile should end with three single-measure tiles and the same three numbers on screen — check the rendered grid afterwards, because the two new tiles are numbers the dashboard was ALREADY paying to compute and had never shown.
  • dashboard-widget-single-series-multi-measure-refused — dashboard widget measure arity WITH a dimension on a single-series chart type — dashboard.widgets[].values (DashboardWidgetSchema.values) on a widget whose dimensionsdeclares one or more dimensions and whosetypeispie, donut, funnel, treemaporsankey; and the check export checkDashboardWidgetDimensionlessMeasureArity(from@objectstack/spec/ui), renamed checkDashboardWidgetChartMeasureArity`` → Keep ONE measure on the widget, or pick a visual that renders several. With a dimension, type: 'table' renders a column per measure and a bar-family type (bar / column / horizontal-bar) renders one bar per measure in each category; both keep the unbounded values they have always had. Or keep the type and give each measure its OWN widget: a new id, the same dataset and dimensions, that one measure in values, and its own layout if the dashboard pins grid positions. ⛔ The migration does not do this for you and no conversion could: whether a two-measure pie by stage meant a table, a grouped bar chart or two pies is an authoring choice, and N widgets need N ids and N boxes on a 12-column grid, which is a LAYOUT decision about a dashboard the registry has never seen. A mirror that chained the old check export by name imports checkDashboardWidgetChartMeasureArity instead: same signature, same attachment point, and it refuses everything the old name refused.
    • Why not automatic: Triage's ruling on objectui's finding that a dimensioned pie draws only its first measure: the spec refuses, the renderer does not invent. It extends dashboard-widget-dimensionless-multi-measure-refused, which applied maintainer ruling D (「协议不正确的应该先修改协议」) to a widget with NO dimension, to the dimensioned arm for the five types that draw one series whatever the dimension. Measured in objectui's shared chart renderer: the pie / donut, funnel, treemap and sankey arms each bind the first series and read no other, so { type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] } drew one slice per stage for revenue and no trace of cost — the dataset query selects and computes every measure, and all but the first are thrown away. Every door accepted the document, because the dimensionless rule stepped aside for any widget that declared a dimension. That is the declared≠delivered shape ADR-0049 exists to end. A pie of several measures has zero measured pull, so no rendering is invented for it. The census before the change found zero authored dimensioned multi-measure widgets of the five types in the platform's examples, its first-party dashboards or objectui's example apps, so this ships at once with no deprecation window. Relaxing later is free and needs no second migration — a type whose renderer gains a declared rendering for several measures with a dimension leaves the single-series set — while leaving the shape accepted costs an author a widget that silently drops what they declared. The check export is renamed in the same change because its old name said a widget with a dimension was outside it, which stopped being true; no first-party consumer chained it under that name.
    • Done when: ⚠️ WHICH DOOR: the refusal is the spec's, attached at the same point as the dimensionless rule, so it reaches every door that parses the spec schema — measured on defineStack, which throws naming the widget, and on the stack schema, the dashboard schema and the dashboard metadata-type schema, each refusing at widgets[N].values. It is NOT refused by objectui's client-side authoring door until that door chains the export: @object-ui/types builds its DashboardWidgetSchema from a .shape spread of the spec's, which carries the FIELDS and drops every object-level check, so its editor keeps accepting a dimensioned two-measure pie and the author meets the refusal at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. ⚠️ AND THE TODO CANNOT NAME YOUR MEASURES: a SemanticMigration is static prose emitted once per hop, with no per-document interpolation and no filtering by whether the stack carries the shape, so os migrate meta prints THIS paragraph, not a list of your widgets. The refusal is what names them, per widget, on the re-parse — drive the fix off os validate, not off the migrate output. WHAT IS REFUSED, exactly: ONE custom issue at widgets[N].values, naming the widget's id, the number of measures and the authored type, and saying the type draws one series whatever its dimensions, when dimensions declares at least one dimension, values carries two or more measures, and type is pie, donut, funnel, treemap or sankey. The check is the exported checkDashboardWidgetChartMeasureArity — the dimensionless rule's own check, with a second arm; the single-series set it reads is not exported, and the refusal prints it. WHAT IS NOT, so this is not read as complete: scatter and radar WITH a dimension keep accepting several measures exactly as before (the ruling named the five: radar draws every series, and scatter says on the chart that it draws one); every type in DASHBOARD_WIDGET_MULTI_MEASURE_TYPES keeps accepting any number of measures with a dimension; one measure parses on every type; a DIMENSIONLESS widget of the five keeps the dimensionless refusal, word for word and still ONE issue; the metric family (metric / kpi / gauge / solid-gauge / bullet, and a widget that declares no type, which resolves to metric) keeps its OWN refusal, unchanged; an EMPTY values keeps the field's own too_small; a type outside ChartTypeSchema reports the TYPE refusal alone, and called directly on a wider type enum the export judges only the types the spec declares; and whether each measure EXISTS in the bound dataset is still unreachable from this schema. VERIFY by re-parsing each dashboard: a dashboard that had one two-measure pie by stage should end with a table or bar-family widget carrying both measures, or with two widgets of one measure each — check the rendered grid afterwards, because the second measure is a number the dashboard was ALREADY paying to compute and had never shown.
  • dashboard-widget-stage-order-non-funnel-refused — dashboard widget stage order — dashboard.widgets[].options.stageOrder (DashboardWidgetOptionsSchema.stageOrder) on a widget whose typeis anything other thanfunnel, INCLUDING a widget that declares no typeat all and so resolves to themetric default → either type: 'funnel' on the widget that meant to declare a stage order, or — for every other widget type — DELETE stageOrder and order the widget with options.sortBy + options.sortOrder, which lower into the dataset query as order: { <name>: 'asc' | 'desc' } instead of re-sorting what it returned. There is no third spelling: no other widget type has ever read the key, so nothing is lost by removing it that was not already absent from what rendered. The refusal lands at options.stageOrder and names the type the widget carries, the one type that reads the key, and the two keys to reach for instead.
    • Why not automatic: The first finding of the report that options.stageOrder is honoured by the funnel branch only, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose whole content was SILENCE. options is the open renderer-extras bag, so stageOrder was an ungated member of it: a horizontal-bar (or line, pie, table, metric) widget carrying an authored lifecycle order PARSED, booted, and forwarded the array to the renderer, which never consulted it. Measured at this repo's .objectui-sha pin 53ded82bf7a494f54e344e19099dbf00854b8694: the forwarded categoryOrder prop has exactly one read in the charts plugin (buildCategoryRank(categoryOrder), AdvancedChartImpl.tsx:1514) and it sits inside the chartType === 'funnel' guard opened at line 1473; the prop's other two occurrences in that file are its declaration and its destructure. The producer side has no gate either — DatasetWidget.tsx:1468 builds the explicit order for ANY widget and forwards it whenever non-empty. So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there, with nothing anywhere to say so: the widget rendered in whatever order the analytics query returned and looked deliberate. The reporter measured exactly that in a live app — a horizontal-bar carrying a seven-stage contract lifecycle rendered alphabetically by display label. The four SIBLING members of the same bag are not in this narrowing and were measured not to share the defect: dateGranularity, sortBy, sortOrder and limit are read unconditionally at the top of DatasetWidget (lines 443-455, outside every type branch) and lower into the DatasetSelection the server compiles, so they act on every widget type. stageOrder was the only member whose effect was confined to one branch. ⛔ NOT the other arm of the card ("or ordered marks honour it"): teaching bar / line / area to sort by a category order is a renderer change in the objectui repo, and widening the set of types that read the key can be done later WITHOUT a second migration — a narrowing that is later relaxed costs an author nothing, while leaving the key accepted-and-inert costs them a chart that silently lies. Ships at once, no deprecation window: there is no window in which an inert key does anything.
    • Done when: ⚠️ WHICH DOOR: this refusal is the PUBLISH door's, not the editor's. Every stored dashboard whose widgets carry options.stageOrder on a non-funnel type is refused the next time it is parsed THROUGH @objectstack/spec — os build / os lint, the metadata publish path, and any server-side door that parses the spec schema — with one custom issue at widgets[N].options.stageOrder naming the authored type. It is NOT refused by objectui's client-side authoring door: @object-ui/types builds its own DashboardWidgetSchema from specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict(), and a .shape spread carries the FIELDS while dropping every object-level check (measured: z.strictObject(DashboardWidgetSchema.shape) accepts the widget and reports zero checks, while .extend({}) keeps the refusal). At the .objectui-sha pin 53ded82bf7a494f54e344e19099dbf00854b8694 that package re-attaches NONE of the spec's exported checks, so until it imports and chains checkDashboardWidgetStageOrder the dashboard EDITOR still accepts the key on a bar and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a clean dashboard; re-parse through the spec. Fix each by writing type: 'funnel' where a funnel was meant, and by deleting the key elsewhere — check the rendered order afterwards, because a widget that was silently ignoring the key renders EXACTLY as it did before once the key is gone, and sortBy / sortOrder is what changes it. A funnel widget carrying stageOrder parses byte-identically to before, a non-funnel widget carrying the other four options members is untouched, and a widget with no options at all is untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete (the objectui door above is the first): a widget whose type is outside ChartTypeSchema reports the TYPE refusal alone (zod treats that as aborting and skips object-level checks), so the stage-order refusal arrives only on the next parse; and the array's CONTENTS are still unconstrained, so a funnel carrying a stage value the dimension never declares still parses and still renders that stage in the sentinel position; and a consumer that derives this schema with .omit() / .pick() / .partial() now gets a THROW from zod rather than a schema, because zod 4 refuses all three on an object carrying a refinement — latent rather than live (no consumer in either repo derives the widget schema that way today), and .extend() is unaffected. Repo census at the time of the change: zero authored widgets carry the key anywhere in the monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated reference pages, the sdui-parser census and the gate that derives it.
  • data-file-value-duration-unit-in-key — FileValue.duration, the media length on the expanded file/image/avatar/video/audio read shape, whose name carried no unit (data/field-value.zod.ts) → durationSeconds — rename the key; the value is unchanged, and a fractional second is still legal
    • Why not automatic: Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express: rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. This key declared its unit in NO channel at all — no .describe(), no JSDoc, no unit token in the name — so the published reference page printed a bare number and the authoring site printed nothing. What makes the bare name worth a registry row is the company it kept: the only other number on FileValue is size, a BYTE count, so the one member that measured time was indistinguishable from a count at the very site an author (very often a model, ADR-0033) writes it. durationSeconds rather than a mechanical durationSec or lengthSeconds: this spec already spells a length of time durationSeconds in SIX places, and they are ENUMERATED rather than counted because a bare number in shipped prose cannot be re-checked against the tree — ai/conversation.zod.ts ConversationAnalytics.durationSeconds; and on system/metrics.zod.ts MetricAggregationConfig.window.durationSeconds, ServiceLevelIndicator.window.durationSeconds, ServiceLevelObjective.period.durationSeconds, ServiceLevelObjective.errorBudget.burnRateWindows[].durationSeconds and MetricsConfig.retention.durationSeconds. The media length is therefore the SEVENTH spelling of one vocabulary, not the first of a second one. The retired-key tombstone entry data/FileValue:duration carries the same six keys in the same order, so the two surfaces that state one fact cannot drift apart. The value type is deliberately UNCHANGED at z.number().optional(): a fractional second is the ordinary shape of a media length, so the closed DurationSeconds type (.int().nonnegative(), published beside EpochMs as a closed duration type) was considered and REFUSED by the ruling, and so was an .int() floor. That refusal is the load-bearing half — this row is one of the six genuine durations the closed types' unit set was derived from, and it is the one that takes a NAME instead of a TYPE. Tombstoned with retiredKey(); FileValueSchema is the one deliberate z.looseObject in this file, so a bare deletion would wave the old spelling through as an unrecognised extra key and the prescription would never be spoken. Why a semantic entry and not a D2 conversion: FileValueSchema is the ADR-0104 D3 wave-2 EXPANDED READ form, derived at read time from a sys_file id — the STORED form is FileReferenceIdValueSchema, an opaque string — so a file value is never authored as this shape and never persisted as a sys_metadata row, and the conversion chain has no seam that would ever see one. ADR-0104, ADR-0087.
    • Done when: Every producer that BUILDS an expanded file value spells durationSeconds, and every consumer that reads a media length reads durationSeconds. Authoring duration fails to compile (input type never) and fails to parse with the rename prescription rather than riding through the loose shape as an unrecognised extra. Behaviour is unchanged: durationSeconds: 12 is the same twelve seconds duration: 12 was, the key stays optional, and durationSeconds: 12.34 still PARSES — a sweep that added .int() or adopted DurationSeconds has narrowed a value the ruling refused to narrow, and is the one over-application to look for. The five sibling members — url, name, size, mimeType, alt — are untouched; size in particular is a BYTE count, not a duration, so a sweep that suffixed it has read a count as a length of time.
  • data-nosql-query-options-timeout-unit-in-key — NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no unit (data/driver-nosql.zod.ts) → timeoutMs — rename the key; the value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file. The neighbour is what makes it a real hazard rather than a naming preference: batchSize sits directly beside it, a plain row COUNT with the same z.number().int().positive() shape and the same order of magnitude, so two adjacent bare integers meant milliseconds and documents respectively with nothing at the call site to separate them. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and the query would run with no deadline at all while its author believed one was set — the failure a driver timeout exists to prevent. Why a semantic entry and not a D2 conversion: these options are a per-call driver argument, reached only through AggregationPipeline.options, which no stack.zod.ts collection declares and no sys_metadata row stores, so the chain has no seam. ADR-0087.
    • Done when: Every caller that passes NoSQL query options spells timeoutMs. Authoring timeout fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: timeoutMs: 5000 is the same five seconds timeout: 5000 was, and the positive-integer bound rides along with the renamed key so a zero or negative deadline is still refused. Two neighbours on this same shape deliberately do NOT move, and a sweep that renamed either has over-applied the rule: batchSize is a COUNT of documents, not a duration, and consistency / projection / hint are not numbers at all.
  • dataset-filter-nested-relation-equality-array-refused-at-save — ui.Dataset filter and ui.DatasetMeasure filter — an ARRAY as an EQUALITY comparand on a field INSIDE a nested-relation condition, now refused when the dataset is PARSED: the implicit form { account: { region: [...] } } and the explicit form { account: { region: { $eq: [...] } } }, the empty array included, at any relation depth and under $and / $or / $not. Every other schema that carries a FilterCondition keeps the shared schema's reach → the operator the list was standing in for, on the same field inside the same relation, exactly as in filter-equality-array-comparand-refused-at-save. "One of these values" is $in: { account: { region: { $in: ["a", "b"] } } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { account: { region: "a" } }. The list operators keep their arrays, empty lists included; every scalar, null above all, is untouched; and $ne is NOT judged by this entry
    • Why not automatic: Measured on origin/main 9e7824a445 before the change: DatasetSchema parsed a dataset whose filter was { account: { region: ["a"] } }, and one whose measure filter was { account: { region: { $eq: ["a"] } } }, GREEN — while the analytics where door, which charts both carriers on every path (the native-SQL and ObjectQL strategies and the draft preview), flattens the relation to the dotted member account.region and hands the list to the shared comparand-shape face, which refuses it with INVALID_FILTER / 400. So such a dataset saved clean and every chart built on it failed, for a different person, later. The shared FilterConditionSchema does not descend a field spec with no $ key, because the engine reads one as a deep-equality comparand; ruling A of 2026-09-24, which made the schema door refuse what the compile face refuses, drew the line there and it stays there. Triage on 2026-09-25 routed the fix to the two analytics carriers instead, rather than stop the analytics door descending, which would change what a nested list means: they refine their filter with the analytics door's own walk ($and / $or arrays and $not descended, other $ keys skipped, a plain object with no $ key descended as a nested relation at any depth) and refuse, inside a nested relation only, exactly what that door refuses there, in the face's words from the one builder both doors import. The one difference is that the door appends the location (at where.account.region) and the carrier does not, because its issue carries the location as its path (filter.account.region, measures.0.filter.account.region.$eq). A list outside a nested relation is the shared schema's refusal and is reported once. No filter changes meaning: the refusal moves from chart time to save. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored dataset keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every chart since the analytics door began refusing it, so the refusal is a repair and not a loss. In-repo census at 9e7824a445: no dataset or measure filter in examples, platform objects, docs or skills carries the shape; deployed datasets were NOT measured. ADR-0021 / ADR-0087.
    • Done when: Validate every stack and re-save every stored dataset: os validate or defineStack, and a save through the metadata protocol, report each list in an equality slot inside a nested relation by path, with the field, the received list and both remedies, so the sweep of the two carriers is mechanical. Decide per filter what it meant — one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value — and re-check what each chart is supposed to show: the filter had been failing every chart. A filter that reaches the analytics door by any other route, such as a caller where or a dataset selection runtimeFilter, is still refused only when it is charted, with INVALID_FILTER / 400 naming the field and the path.
  • dataset-measure-aggregate-field-type-refused — dataset measure aggregate×field pairs (DatasetMeasureSchema, the rows inside Dataset.measures[]) over a TEMPORAL field — date, datetime, time— whose aggregate that declaredFieldTypecannot carry:avgandsumover any of the three. ⚠️ This entry is ONE OF TWO on this leg, and its scope sentence is kept as written: it covered the temporal class and nothing else when it was registered. The non-temporalsum/avgrows followed in a later change, which registered NO entry of its own — it declarednot-required (already-registered dataset-measure-aggregate-field-type-refused)against THIS id — so its widening rides this entry's prescription rather than a separate one. Thecount_distinct row's JSON-stored types (json, composite, repeater, record, location, address, vector, multiselect, checkboxes, tags) left the table in a third change, which likewise registered no entry and rides this one: no two backends compare those values for equality alike, so countis the aggregate that stays. Themin/maxrows over every class the table refuses are the second entry,dataset-measure-selecting-aggregate-field-type-refused. ⇒ Read BOTH when migrating; there is no third → an aggregate the field's type accepts, per AGGREGATE_FIELD_TYPE_COMPATIBILITY (@objectstack/spec/data): min / max for a temporal field — both return a real instant of the field's own type — or count / count_distinct, which read no arithmetic off the value. A DURATION is not recoverable from an aggregate over instants: store it as a number (a computed "days open" field) and aggregate that. A derived measure whose of names a refused measure is fixed by fixing that measure, not the derived one
    • Why not automatic: Nothing between the author and the driver correlated a measure's aggregate with its field type, so avg over a Field.datetime compiled to AVG(col) and reached the backend — where the ANSWER was decided by the dialect rather than by the data. Measured on both halves: SQLite coerces the column's canonical UTC text to a number by reading its leading digits, so avg(submitted_at) over 2026-05 and 2025-01 returns 2025.5 — the average YEAR, no error, no log; PostgreSQL 16 answers function avg(timestamp with time zone) does not exist (SQLSTATE 42883). ⚠️ The two halves are not evidenced alike: the SQLite half is PINNED by a live sql.js suite in __tests__/aggregate-datetime-measure-refusal.test.ts, while the Postgres half was MEASURED IN-SESSION on PostgreSQL 16.13 and is not pinned by any test — the live PG conformance job carries no cell for it. Nothing depends on it: the refusal is decided from declared metadata before a driver is reached. ⭐ The silent half is the dangerous one, and it is the DEV default: derived: { op: 'difference', of: [avg_a, avg_b] } over two such averages rendered -0.85 on a tile labelled "average cycle time delta" — indistinguishable from a correct answer, which is the shape Prime Directive #12 exists to remove. Which pairs are accepted is therefore a contract, declared once in @objectstack/spec under the director's ruling of 2026-09-06 ("both legs, table in spec") and executed by the consumer legs; the compile-time leg (dataset-compiler, service-analytics) refuses the pair with DATASET_INVALID / 400 before any query is built, using the declared type the host already supplies through AnalyticsServiceConfig.sourceFieldMeta. ⚠️ A date / datetime used as a DIMENSION — grouping, bucketing, date-range filtering — is untouched: this is about aggregation only.
    • Done when: Every dataset measure over a date / datetime / time field pairs that field with an aggregate the temporal class accepts — min, max, count, count_distinct — and none pairs it with avg or sum. ⚠️ The criterion as WRITTEN reaches no further: a measure over a field of any other class was not judged by the leg this entry was registered for. It is covered all the same — by this entry's own prescription, widened by a later change (which registered not-required against this id rather than an entry of its own) to sum / avg over every field class, and by a third, the same way, to count_distinct over the JSON-stored types; and by dataset-measure-selecting-aggregate-field-type-refused for min / max. ⛔ There is no third entry to look for. At protocol major 18 as a whole, every refused pair in AGGREGATE_FIELD_TYPE_COMPATIBILITY is refused at the compile door. Accepted pairs compile and execute byte-identically to before (avg over number / currency, min / max over datetime, count over anything); a refused pair answers 400 DATASET_INVALID naming the measure, the field, its declared type and the accepted set, with no SQL emitted. The refusal stands down rather than guessing wherever the type cannot be resolved: no sourceFieldMeta wired, an unknown field, or a relationship.field path whose column lives on a joined object.
  • dataset-measure-selecting-aggregate-field-type-refused — dataset measure aggregate×field pairs (DatasetMeasureSchema, the rows inside Dataset.measures[]) pairing minormaxwith a field whose declaredFieldType that aggregate cannot carry — every type outside the numeric, temporal and boolean classes. Named in full so an author can grep their own metadata: the string family (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode), the option types (select, radio), the references (lookup, master_detail, tree, user), autonumber, the multi-option types (multiselect, checkboxes, tags), the file family (image, file, avatar, video, audio), the structured-JSON types (json, composite, repeater, record, location, address, vector) and formula — 37 field types × 2 aggregates = 74 pairs → an aggregate the field's type accepts, per AGGREGATE_FIELD_TYPE_COMPATIBILITY (@objectstack/spec/data), or a different way of asking the question. ⚠️ There is no lossless rewrite, which is why this is a semantic TODO and not a D2 conversion: nothing can compute "the smallest text value" in a way every backend agrees on, so no transform can preserve the answer. The three routes an author actually has, per intent: ① the measure was COUNTING in disguise ("how many distinct owners") ⇒ count, which accepts every type because it reads no value, or count_distinct, which accepts every type but the JSON-stored ones (the structured-JSON types and multiselect / checkboxes / tags, whose values no two backends compare for equality alike); ② the measure wanted a FIRST or LAST RECORD ("the earliest-titled task") ⇒ that is a SORT on a list or report, which orders once in a declared direction, not an aggregate that asks each backend for its own smallest value; ③ the measure wanted a QUANTITY that happens to be stored as text or JSON ⇒ store it as a numeric or temporal field (a computed column) and aggregate that. A derived measure whose of names a refused measure is fixed by fixing that measure, not the derived one
    • Why not automatic: Director ruling B of 2026-09-13: the compile door enforces the table for every aggregate. The table refused these 74 pairs from the day it was declared and NOTHING executed the refusal: the compile leg (dataset-compiler, service-analytics) carried an explicit scope condition — if (!DERIVING_AGGREGATES.has(aggregate)) return; — so min / max were never judged whatever the field type, and service-analytics' measureResultType went further and typed min / max over the string classes as a supported 'string' result and over a formula field from its declared returnType. Four declarations, three answers, one pair — the worst shape of declared≠enforced, because nobody could tell which sentence was the contract. ⭐ The divergence is real and it is the ORDER rather than the arithmetic: string order is collation-dependent, so two backends answer two different "smallest" values for one metadata document, and min(jsonb) does not exist on PostgreSQL at all — the same shape Prime Directive #12 exists to remove. The ruling settled all three sub-questions together rather than per field class, because one shared fixture drove members of both halves: the string classes stay REFUSED as the director's 2026-09-06 ruling put them (「min/max numeric plus date/datetime; everything else refused」) and the table is NOT amended; the non-string classes are refused AND enforced; and formula is refused on the table's own storage ground — it is VIRTUAL in SQL storage, no column is emitted, so no aggregate can be lowered to it whatever returnType says. ⚠️ The "ruled C — the table is to be AMENDED to accept the string rows" note the tree carried in two test files had no ruling behind it: the card it cited is closed as a duplicate with zero rulings on it, and the earlier recorded ruling on this table says the opposite. Business pull was measured and is zero — the shipped min / max cases were in-tree fixtures pinning a result TYPE, not customer datasets reading one. ⚠️ Confidence gap, recorded rather than hidden: customer datasets in the cloud repository were not readable when this was decided.
    • Done when: Every dataset measure declaring aggregate: 'min' or 'max' pairs it with a field the class accepts — the numeric class (number, currency, percent, rating, slider, progress, summary), the temporal class (date, datetime, time) or the boolean class (boolean, toggle) — and none pairs it with a field of any other declared type. Accepted pairs compile and execute byte-identically to before, including min / max over a temporal field, which still carries fields[].type: 'time'; a refused pair answers 400 DATASET_INVALID naming the measure, the field, its declared type and the accepted set, with no SQL emitted. ⚠️ A text / select / lookup / formula field used as a DIMENSION — grouping, labelling, bucketing, filtering — is untouched, and so is count / count_distinct over one: this is about the two SELECTING aggregates only. The refusal stands down rather than guessing wherever the type cannot be resolved: no sourceFieldMeta wired, an unknown field, or a relationship.field path whose column lives on a joined object. A measure column over such a pair also stops carrying a corrected fields[].type, because the pair no longer produces a column at all.
  • dataset-member-field-expression-refused — datasets[].dimensions[].field and datasets[].measures[].field (ui.DatasetDimensionSchema.field / ui.DatasetMeasureSchema.field) authored as anything but a column reference — a SQL expression (an arithmetic, an aggregate, a CASE, a subquery, a function call), a quoted or $-prefixed spelling, a padded or empty string, a broken path, or * on a dimension → a column reference: a field of the dataset's object (amount), or a relationship path ending in one (account.amount) whose relationships are declared in include; on a measure also '*' for a count, and a count may omit field altogether (never field: ''). A derived value takes its ADR-0021 form: a conditional count or sum is a measure with its own structured filter ({ name: 'done_count', aggregate: 'count', filter: { status: 'done' } }), and a ratio, sum, difference or product of measures is derived: { op, of: [...] } over measures named in the same dataset ({ name: 'done_rate', derived: { op: 'ratio', of: ['done_count', 'task_count'] }, format: '0.0%' }). A dimension that bucketed a column with an expression has no expression form: group by the column itself, or keep the bucket as a field of the object and name that field
    • Why not automatic: The dataset layer was declared to take no raw SQL (ADR-0021 "zero raw SQL / zero raw expressions"), and its field was documented as a field or a relationship path, but the slot was a bare string and parsed anything — declared, never enforced (ADR-0049). The runtime had already closed the other end for an expression: the analytics dataset door refuses one with a 403 refusal, inline or saved, because an expression names no single field and no platform check can judge which fields it reads. So an expression could be saved and never answered. That door never judged an empty field — it skips one. The cube members a dataset compiles to were narrowed to the same accept set earlier (cube-member-sql-expression-retired); the dataset compiler copies field into the member's sql verbatim, so the two slots now share one declaration. A dimension additionally refuses '*': grouping by every column is no axis, and both analytics strategies answered such a dimension with a 500 database fault. An empty string is refused on both: a dimension groups by nothing, and a count spells "no field" by omitting the key. One empty string had a working row and has a lossless repair: a count measure with field: '' (the shape a blank Field box in Studio's dataset inspector stores) compiled to the row count on SQLite's native-SQL path, and without the key it compiles to COUNT(*) — the D2 conversion dataset-count-measure-empty-field-removed drops it from stored rows and sources. Everything else has no mechanical rewrite into a column: an expression becomes a measure filter, a derived measure or a field of the object, and a ratio changes scale on the way (a derived ratio is a 0–1 fraction, so an expression that multiplied by 100 returned percentage points). ADR-0021 / ADR-0049 / ADR-0087
    • Done when: Every dataset parses: DatasetSchema, the dataset write door and defineStack refuse a non-column field at dimensions.N.field / measures.N.field with a prescription that names the column-reference contract and the ADR-0021 form, so the sweep is mechanical — parse each dataset, and each refusal is one member to change. A count measure that carried field: '' loses the key by the D2 conversion, parses, and still counts rows; a non-count measure or a dimension with an empty field is left as stored and refused until it names a column. For each moved measure, a query over a fixture where the condition excludes rows returns the figure the expression meant (a ratio: the same value divided by 100 when the expression returned percentage points). A dimension or measure whose field is a column or a relationship path parses byte-identically to before.
  • datasource-config-mongo-options-credential-refused — datasource.config.options.auth.password (mongodb) — a login credential written into the MongoClient options passthrough → remove the auth block from options (its other keys — replicaSet, tls, timeouts — stay legal) and bind the secret: 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, with the username kept in the URL (mongodb://user@host/db)
    • Why not automatic: The FOURTH spelling of the same inline secret: earlier publish refusals closed the top-level password key, the URL userinfo password and the credential-bearing URL query parameters — and the options passthrough stayed open one syntax over. options: { auth: { username, password } } parsed green, persisted the password cleartext into sys_metadata (served back by the ordinary data API), and genuinely authenticated: mongodb@7.5.0 transforms the block into MongoCredentials (measured), so the workaround was live, not inert. A non-empty string auth.password is now refused at publish with the binder prescription; auth.username alone stays writable (the asymmetry the URL grammar keeps between its two userinfo halves — a username is not credential material), as do all non-credential passthrough options. The bound secret wins over a passthrough auth block at connect (measured when the bound secret was made to reach the mongo client on its URL branch), so the replacement changes which store holds the secret, never which credential connects. There is no mechanical rewrite, for the same reason as the sibling entries datasource-config-inline-credential-refused, datasource-config-url-userinfo-refused and datasource-config-url-query-credential-refused: moving the value requires ENCRYPTING it into a sys_secret row through a running secret binder, which a source-file transform cannot do — and auto-dropping only the nested password would leave an auth block the client refuses at construction (measured: credentials must be an object with 'username' and 'password' properties). Runtime-environment DSNs (OS_DATABASE_URL and friends) never pass through the publish door and are unaffected by construction. The read path now also redacts the stored passthrough secrets (options.auth.password, options.proxyPassword, TLS key material, AWS_SESSION_TOKEN) instead of serving them back in cleartext.
    • Done when: Every mongodb datasource parses with no auth.password inside config.options; each affected datasource carries external.credentialsRef (or has its secret bound through the connection form) with the username in its URL, and still connects; no passthrough credential remains in any stored sys_metadata row or authored source.
  • datasource-config-options-nested-credential-spelling-refused — datasource.config.options.**: any credential-SPELLED key (password, authToken, or a former alias — passwd/pwd/token/jwt/auth_token/authtoken) holding a non-empty string at any object depth of the mongodb options passthrough → remove the nested key (no measured client behaviour reads any such position other than auth.password, which has its own refusal); if a real secret must reach the connection, bind it — the Setup → Datasources connection form's secret field (encrypted into sys_secret, handle stored at external.credentialsRef) or a direct external.credentialsRef reference
    • Why not automatic: The nested-position closure of the datasource-config-mongo-options-credential-refused family: that entry refused the one MEASURED login position (options.auth.password) and left every other nested spelling of the same secret accepted — options.auth.token, options.pool.password, any credential-spelled key one object level down parsed green, persisted cleartext into sys_metadata (served back by the ordinary data API), and was served on the datasource read doors with redactedConfigKeys: [] because the read-side nested judgment was a hand-enumerated path table. Publish now refuses a non-empty string under any credential SPELLING at any object depth of the passthrough — the same one spelling list the top level refuses and the read path redacts, so a nested position is treated identically to the top-level key it mirrors. Arrays are off the walk (row-shaped data is not config). The read path now also redacts these spellings at every depth, for every driver, and carries them forward on an untouched Save. There is no mechanical rewrite, for the same reason as the sibling credential entries: moving a value into sys_secret requires a running secret binder, which a source transform cannot do — and unlike auth.password, a nested spelling at an unmeasured position buys nothing at connect, so the usual outcome is deletion, which only the author can confirm.
    • Done when: Every mongodb datasource parses with no non-empty credential-spelled string at any object depth of config.options; any real secret found there is re-bound through external.credentialsRef (or the connection form) and the datasource still connects; no nested credential remains in any stored sys_metadata row or authored source.
  • datasource-config-postgres-url-unparseable-refused — datasource.config.url (postgres) — connection URLs the pgclient cannot parse (libpq's multi-hosth1:5432,h2:5433form, a non-numeric port, a scheme-less non-URL, a malformed percent-escape), plus the filesystem-reading query parameters?sslcert=/?sslkey=/?sslrootcert=`` → a single-host URL pg itself parses — postgresql://[user@][host][:port][/dbname][?params] (unix-socket forms stay accepted: a leading-/ path, socket:, or a percent-encoded socket host). For a multi-host cluster, point the URL at one node or at a proxy/pooler in front of the cluster — pg does not implement libpq's multi-host DSN, so no spelling of it can connect. For certificate material, use the datasource-level ssl block (ssl: { ca: …, cert: …, key: … } next to driver) instead of file-path query parameters
    • Why not automatic: PostgresConfigSchema.url's own describe text documents the postgres URL grammar, but until protocol 18 the value was only string-scanned for credentials (the URL userinfo password and credential query parameters) and ${…} placeholders — deliberately so at the SHARED helper, whose refusal to parse is load-bearing for mongo's multi-host/+srv forms (new URL() rejects the multi-host form outright, and the mongo arm hands the authored URL to its client untouched). For postgres that leniency was no check at all: pg@8.22.0 does not implement libpq's multi-host DSN — both pg-connection-string's parse and pg's ConnectionParameters throw TypeError [ERR_INVALID_URL] on postgresql://app@h1:5432,h2:5433/app (measured) — so an operator could publish exactly that URL, see it saved, and discover only at connect time that it can never open a connection, via a bare Invalid URL whose input field pg redacts. The refusal now asks the same grammar one door up: parse from pg-connection-string (the parser pg itself uses) runs at publish, per-driver, and what it throws on is refused with the value's path named. Two adjacent shapes are refused as structurally unusable rather than parse-refused, both measured: a scheme-less value "parses" only by resolving against the parser's placeholder base (postgres://base), i.e. pg would connect to the literal host base with the authored text as the database name; and ?sslcert=/?sslkey=/?sslrootcert= make parse itself call fs.readFileSync, so the verdict would depend on the validating host's filesystem — certificate material already has its declared home in the datasource-level ssl block. There is no mechanical rewrite: a URL pg cannot parse does not carry enough structure to say which single host the author meant (a multi-host DSN names several on purpose), so the choice of target is the author's. Runtime-environment DSNs (OS_DATABASE_URL and friends) never pass through the publish door and are unaffected by construction.
    • Done when: Every postgres datasource parses with a config.url that pg-connection-string parses without throwing, that carries a scheme (or is a unix-socket path), and that carries no ?sslcert=/?sslkey=/?sslrootcert= query parameter; each affected datasource still connects to the intended single host; certificate material, where needed, lives in the datasource-level ssl block; mongo/mysql/turso datasources are byte-identical before and after (their URL checks are unchanged).
  • datasource-config-url-query-credential-refused — datasource.config.url / datasource.config.syncUrl (turso) and datasource.config.url (postgres) — credential-bearing URL query parameters (?authToken=on turso,?password= on postgres) → the same URL with the credential query parameter removed (non-credential parameters such as ?tls= / ?sslmode= stay 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 the next refusal the URL userinfo spelling; the query string was the third spelling of the identical secret, one syntax over. libsql://x.turso.io?authToken=eyJ… landed in sys_metadata cleartext exactly as config.authToken did — and at connect @libsql/core assigns the URL token OVER the binder-injected one (measured), so the workaround also silently defeated the bound secret; pg-connection-string likewise honours ?password= over userinfo (measured). Only parameters a measured client actually reads are refused: mysql and mongo ignore ?password= (measured), so their URLs are unaffected. Runtime-environment DSNs (OS_DATABASE_URL, OS_DATABASE_AUTH_TOKEN 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 entries datasource-config-inline-credential-refused and datasource-config-url-userinfo-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 parameter 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 no credential-bearing query parameter in config.url / config.syncUrl (no ?authToken= on turso, no ?password= on postgres); 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.
  • datasource-credentialsref-mongo-composed-no-username-refused — datasource (mongodb) — external.credentialsRefbound whileconfigauthors nourland names nousername`` → decide what the datasource is meant to do, then make the two halves agree: add username to config so the bound secret is interpolated beside it into the composed connection URI at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the external.credentialsRef binding (and unbind the orphaned sys_secret row via the Setup → Datasources form). Authoring a config.url that names a user is a third valid shape, judged by the prescription of the sibling URL-branch entry, datasource-credentialsref-mongo-url-no-user-refused.
    • Why not automatic: The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. With no config.url the driver factory COMPOSES the connection URI from the discrete fields, and the bound secret has exactly one route into it — the userinfo written beside a username (buildMongoUrl: const auth = user ? … : ''). A falsy username closes that route, and the branch has no second one: buildMongoAuth returns early when there is no url, because the composed branch injects THROUGH the URI it builds rather than beside it. So credentialsRef bound with no url and no username composed mongodb://host:port/db, connected ANONYMOUSLY, and told the operator nothing. Nothing can be fabricated to rescue it: a MongoDB handshake cannot authenticate from a password alone — the same measured asymmetry behind the sibling URL-branch refusal. Both branches had always agreed on this input, so this inherits that ruling rather than re-opening it, and lands at the same authoring/publish door — the one place both halves are visible at once — as the "absence must be loud" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: a discrete username that is present and non-empty (the secret is live there — the composed branch has always interpolated it), an empty-string credentialsRef (not a binding — the connect path resolves under a truthy check), a non-string username (the driver config gate already reports the type error), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — pg receives the bound password regardless of the DSN naming a user). An EMPTY-STRING username IS refused, unlike the sibling entry's present-but-empty userinfo carve-out: there MongoClient itself throws (URI contained empty userinfo section) so the shape is already loud, while here username: '' composes the same userinfo-free URI and connects — silently. There is no mechanical rewrite because the valid fixes are CONTRADICTORY intents — authenticate (name the user) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for.
    • Done when: Every mongodb datasource that binds external.credentialsRef and authors no config.url names a non-empty config.username and connects authenticated as that user; every datasource meant to connect anonymously carries no credentialsRef; no datasource parse reports this composed-branch refusal.
  • datasource-credentialsref-mongo-url-no-user-refused — datasource (mongodb) — external.credentialsRefbound whileconfig.url names no user in its userinfo → decide what the datasource is meant to do, then make the two halves agree: add the username to the URL's userinfo (mongodb://user@host/db) so the bound secret is injected at connect — or, for a datasource genuinely meant to connect unauthenticated, remove the external.credentialsRef binding (and unbind the orphaned sys_secret row via the Setup → Datasources form)
    • Why not automatic: The pair cannot work as written, and until protocol 18 it was accepted in silence at every door it passed. MongoClient credentials need a username as well as a password, and with url present the discrete username field is superseded — the only place the username can come from is the URL's own userinfo. So the connect-time injection of the bound secret on the URL branch is conditional on the URL naming a user: mongodb://app@host/db + bound secret authenticates, while mongodb://host/db + bound secret connects ANONYMOUSLY with the secret unused and the operator told nothing. Injecting anyway was measured worse (mongodb@7.5.0): fabricating an empty username turns a connection that works anonymously today into a guaranteed handshake failure, and refusing at connect would contradict MongoConfigSchema.url's published contract ("bind the secret … and it is injected at connect time") while planting a per-branch asymmetry inside the driver factory — the defect class closed when each DSN branch was made to inject the bound secret its composed branch already used. The refusal therefore lands at the authoring/publish door, the one place both halves are visible at once, as the "absence must be loud" half of the family of driver-factory arms, closed one driver at a time, that each dropped something declared without a word: the optional-driver arms that answered a missing package with no remedy, the turso arm that never read its bound secret, and the mysql and mongo DSN branches that discarded one. Deliberately NOT refused, each measured: the present-but-empty userinfo forms (mongodb://@h/db, mongodb://:p@h/db — MongoClient itself throws MongoParseError: URI contained empty userinfo section), an empty-string credentialsRef (not a binding — the connect path resolves under a truthy check), the composed branch (no url, where the discrete username is live), and every other driver arm (the postgres equivalent is judged on its own client's measurement, never inherited — pg injects on a user-less DSN by its own measured mechanism). There is no mechanical rewrite because the two valid fixes are CONTRADICTORY intents — authenticate (add the username) versus anonymous (drop the binding) — and choosing between them requires knowing what the datasource is for.
    • Done when: Every mongodb datasource that binds external.credentialsRef and authors config.url has a username in that URL's userinfo and connects authenticated as that user; every datasource meant to connect anonymously carries no credentialsRef; no datasource parse reports this URL-branch refusal.
  • declared-index-bare-unique-true-retired — ``indexes[].unique: true on a declared index (objects[]andobjectExtensions[]) — the bare boolean, the one unique spelling whose scope was positional → a stated scope: unique: 'global' (one holder across the whole installation — exactly the index bare true built, which is what the chain writes) or unique: 'organization' (one holder per organization — the driver prepends the NULL-safe organization key part COALESCE(organization_id, '__global__') to fields at registration). unique: false / omitted is unchanged, and field-level unique: true is unchanged and stays valid (it means per organization there)
    • Why not automatic: The mechanical rewrite keeps every index exactly as it was built — 'global' IS the verbatim column list bare true materialized, so nothing on disk changes. What the chain cannot know is what the author MEANT. On a declared index bare true read like "unique per organization" to anyone who knew the field-level meaning, and silently built an installation-wide constraint instead: an index meant per organization has been refusing a second organization's value all along, and its refusal told that organization somebody else holds it. Each respelled index is therefore a decision the owner makes once: keep 'global' for a genuinely installation-wide key (a hostname, an external provider id, an engine dedup key), or move it to 'organization' so each organization may hold the value once — a change to the physical index that os migrate plan shows before anything is applied.
    • Done when: No declared index in the sources carries unique: true: os validate passes, and every stored object row reads back with 'global' where it held bare true. os migrate plan against the existing database shows no index operation for an index kept at 'global'. Each index moved to 'organization' appears in that plan as a planned index change.
  • device-request-response-interval-unit-in-key — DeviceRequestResponse.interval (api/auth-endpoints.zod.ts) — the polling cadence in the device-flow response body → intervalSeconds — rename the key; the value (seconds, default 2) is unchanged
    • Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. This key was ATTRIBUTED to RFC 8628 by the campaign card and reached this card only after the attribution failed verification, so the evidence is recorded here rather than left in a PR body. Ruling B exempts a key that mirrors a name fixed outside this repo, declared on the schema as .meta({ externalVocabulary }) — and DeviceRequestResponseSchema does not mirror RFC 8628 as a SET: code is not device_code, verificationUrl is not verification_uri, and expiresAt is not expires_in — a different name AND a different type, an ISO-8601 instant where the RFC carries a relative lifetime. A schema that has already renamed every RFC field it carries into house style cannot claim the standard fixes the one name it left bare. So it is a rename, and deliberately NOT a marker: a wrongly marked key is exempted permanently and silently, while a wrongly renamed one is visible. A SEMANTIC entry rather than a D2 conversion because the shape is RUNTIME-EMITTED — the body of POST /api/v1/auth/device/request, never a stack collection member and never a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087.
    • Done when: No producer emits interval and no consumer reads it. The old spelling is a retiredKey() tombstone, so authoring it fails tsc (the key types never) and fails the parse with the rename prescription. Concretely: a CLI or client polling the device-token endpoint reads intervalSeconds off the request response and waits that many seconds between polls, exactly as interval did — the value and its unit are unchanged, only the key name moves.
  • document-schemas-retired — the document family, retired whole: the four defs data/DocumentTemplate, data/Document, data/ESignatureConfig and data/DocumentVersion, and every name data/document.zod.ts exported from @objectstack/spec/data (DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema, DocumentVersionSchema, their z.input aliases and their Parsed aliases) → a printable document is a PAGE that declares print — no separate template type. Author the document (an invoice, a delivery order, a letter, a report) as an ordinary page with kind: 'full', its blocks in regions drawn from the printable block subset (record:details, record:highlights, record:line_items, element:text, element:image and the rest of PRINTABLE_PAGE_COMPONENT_TYPES), and a print block for the paper, margins, running header and footer, page numbers and page-break hints. A docx-with-placeholders template, a stored document with versions, and an e-signature workflow have no replacement, because nothing on the platform ever merged, stored or sent any of them; a document record the organisation keeps is ordinary object data, and its files are sys_file attachments
    • Why not automatic: ADR-0049 enforce-or-remove, by the ruling of record on the PDF / print document card (letter B′, 2026-10-08): "A document is a page with a print declaration; no new template type", and "The zero-reader DocumentTemplateSchema, DocumentSchema and ESignatureConfigSchema retire in v18 under ADR-0049 with ADR-0087 entries, so that 'template' means one thing." Four defs sat on the exported surface and in the generated reference docs — a docx template with typed placeholders, a document with versioning, access control and an e-signature block, and the signer workflow — and were read by NOTHING: they were exported from @objectstack/spec/data, mounted by no stack.zod.ts key, registered as no metadata type and absent from every liveness ledger, and the reader census over every package, app and example outside packages/spec (generated reference docs, release notes and changelogs aside), over objectui at its pin and its main, and over hotcrm returned zero hits for every exported name, against lit controls. Keeping them would have given an author two meanings of "template" — the dead docx one and the print page — and an AI that imports DocumentTemplateSchema a schema no runtime reads. DocumentVersionSchema had one carrier, DocumentSchema.versioning, and leaves with it. The ESignatureConfig deadline-key tombstones (RETIRED_KEYS_BY_MAJOR[18], D3 esignature-config-deadline-keys-retired) leave with their def's source; their registry entries stay as history. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent), and with no carrier key there is no shape on which a tombstone could sit. cloud and real customer code are UNMEASURED.
    • Done when: No code imports DocumentTemplateSchema, DocumentSchema, ESignatureConfigSchema or DocumentVersionSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/data: every such import is TS2305 after upgrade. A printable document is authored as a page with a print block, which os validate checks: the parse refuses print on a page that does not print its own authored blocks, and the printable block subset refuses any other block inside it. data/DocumentSchemaValidation (the NoSQL driver's schema-validation block, a different declaration) is unaffected. The four defs are absent from json-schema.manifest/data.json, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour.
  • driver-options-timeout-to-timeout-ms — ``DriverOptions.timeout(data/driver.zod.ts) — the per-call options argument of everyIDataDriver method → DriverOptions.timeoutMs (milliseconds) — rename the key; the value is unchanged
    • Why not automatic: Maintainer ruling 2026-09-02 on duration units (ruled B — no grandfathered baseline): the unit of a duration-shaped z.number() key lives in the key NAME, never only in the description. timeout said "Timeout in ms" in prose and nothing else. Tombstoned with retiredKey (DriverOptionsSchema is not strict, so a bare deletion would strip the old key in silence) and registered as data/DriverOptions:timeout. Why a semantic entry and not a D2 conversion: a DriverOptions object is built at a call site and handed to a driver method — it is not a stack collection member and is never stored, so the chain has no seam that runs on it. Measured on ca46f8f12: no in-repo driver reads the key (the engine's own per-call budget is a separate timeoutMs on its options), so callers move their spelling with no behaviour change.
    • Done when: No caller passes { timeout } in a DriverOptions argument; a call spelling it fails to compile (input type never) and DriverOptionsSchema.parse({ timeout: 5000 }) fails with the rename prescription naming timeoutMs; { timeoutMs: 5000 } parses to the same number.
  • driver-remote-doors-tenant-scoped — IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, updateMany, deleteMany, create and bulkCreate on TursoDriver's remote (libSQL) face, called with a tenant context → a tenant-scoped call on the remote face reaches the rows the local face reaches for the same options: the caller's organization, rows with no organization, and under the group posture the caller's membership set. A by-id update outside that scope answers null, a by-id delete answers false, and a predicate write counts only the rows in scope. create stamps the caller's organization on a row that names none. To reach rows of every organization, call without tenantId, as on the local face
    • Why not automatic: The engine hands every driver the caller's organization as DriverOptions.tenantId, and the group posture's membership set as tenantIds (ADR-0131 D8, ADR-0105 D2). TursoDriver's local face applies them through SqlDriver.applyTenantScope on every read and on every update and delete predicate, and stamps the organization on insert. Its remote face compiles its own statements, and its doors received no driver options: their statements carried the caller's filter and nothing else, and a remote create wrote no organization. Where the engine's Layer 0 wall composes a predicate above the driver, that wall held other organizations' rows back. Where it composes none (the posture in which Layer 0 is inert, or an elevated caller that carries its organization), the driver scope is the only fence, and on the remote face there was none. The remote doors now compile the local face's own predicate, by asking the same chokepoint, and AND it onto each statement, so the two faces answer the same rows by construction. The remote create stamps the organization as the local create does. distinct still refuses a tenant-scoped call on the remote face. The call signatures are unchanged, so nothing reaches the compiler. Code that relied on a tenant-scoped remote call reaching another organization's rows now gets the miss answer each door already declares, and a remote create that relied on landing a row with no organization now finds it under the caller's. ADR-0131 D8 / ADR-0087.
    • Done when: No caller of a remote-mode TursoDriver passes tenantId and expects to read, count, aggregate, update or delete a row of another organization; a caller that means to reach every organization calls without a tenant context, as on the local face. No caller relies on a tenant-scoped remote create landing a row with no organization. Proven when a tenant-scoped call on each door answers the same rows on the remote face as on the local face for the same options: another organization's row excluded, null, false or untouched, and the caller's own rows and rows with no organization answered as before.
  • driver-sql-calendar-day-methods-removed — SqlDriver protected methods calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite and calendarDayBetweenRewrite (inherited by SqliteWasmDriver and TursoDriver) → lower the filter before the driver compiles it — lowerFilterCondition(where, { isDatetimeColumn }) from @objectstack/spec/data — instead of calling or overriding a driver method; leave isDatetimeColumn out and the whole-day rule applies to every column
    • Why not automatic: The exported SqlDriver class of @objectstack/driver-sql declared three protected methods that were its own copy of the whole-day rule of ADR-0053 D-D1: on a datetime column, a bare-day inclusive upper bound ($lte '2026-01-05', or the maximum of a $between) compiled as $lt the next day, and the last supported day (9999-12-31) compiled as no upper bound. calendarDayExclusiveUpperBound computed that bound, calendarDayUpperBoundRewrite rewrote a $lte with it, and calendarDayBetweenRewrite rewrote a $between with it. The shared filter lowering in @objectstack/spec/data (lowerFilterCondition) now applies the rule once, at the engine's where seam and at the RLS compile seam, before any driver sees the filter, so the driver's copy was deleted, and the three methods with it (ADR-0053 D-D1 items 5 and 9, as amended). Two consequences reach a subclass, and only one of them reaches the compiler. A subclass that CALLS one of the three, or declares one with override, stops compiling: TS2339 and TS4113, measured with tsc 6.0.3 against the published declaration. A subclass that re-declares one WITHOUT override compiles cleanly, with noImplicitOverride off and also with it on, because the base class no longer has a member to override. That declaration is never called: the driver calls none of the three any more, so the override goes silently dead and the rule it carried stops applying. An untyped JS subclass gets a TypeError at a call and the same silent death for an override. A driver subclass is CODE, never stack metadata, so there is no authored source for the chain to rewrite and no schema tombstone. For the silent half, this entry is the only notice there is: the same disposition as driver-sql-distinct-bare-filter-typed and runtime-httpserver-wrapper-retired. In this repo the one caller was TursoDriver's remote face, changed in the same PR. A read through the engine or the RLS compile seam answers as before, because the seam lowers first; a filter handed to the driver directly is now compared as written. ADR-0053 / ADR-0087.
    • Done when: No subclass of SqlDriver, SqliteWasmDriver or TursoDriver names calendarDayExclusiveUpperBound, calendarDayUpperBoundRewrite or calendarDayBetweenRewrite. Search the source for the three names rather than relying on tsc, because a re-declaration without override compiles and is never called. A subclass that called one to widen a bound hands the driver a lowered filter instead: lowerFilterCondition(where, { isDatetimeColumn }). One that overrode one to change which columns take the whole-day bound passes its own isDatetimeColumn. Proven when, on a datetime column, where: { signed_on: { $lte: '2026-01-05' } } reaches the driver through that path and returns a row stamped 2026-01-05T15:00:00.000Z. Handed to the driver unlowered, the same filter compares against that day's midnight and drops the row. A host that reads only through the engine (find, count, aggregate) or through RLS policies needs no change: those seams lower the filter before the driver sees it.
  • driver-sql-unresolvable-where-column-refused — a wherenaming a column the table does not have, ondriver-sql(and itsTursoDriver/SqliteWasmDriversubclasses) —find()/findOne()answered[]andcount()threw the dialect's own error; both now refuse withINVALID_FILTER/ 400. Theaggregate()door ofTursoDriver's remote face answered []for a missing column or a missing table, and now refuses as the local face does:INVALID_FILTER/ 400 for awherecolumn the table lacks,INVALID_FIELD/ 400 for agroupByor aggregation column the table lacks, andDATABASE_ERROR / 500 for an object whose table is absent → name a column the object actually has, or run schema sync so a recently declared field exists as a column before filtering, grouping or aggregating on it, and so the object's table exists. A caller that legitimately wants "no rows unless this matches" gets that from a predicate over a real column; there is no spelling of an unresolvable column that means "match nothing", which is exactly what the old empty list was mistaken for
    • Why not automatic: One predicate had two answers. SqlDriver.findRows() carries the unknown-column recovery ladder (an unsortable query loses its ORDER BY, not its rows), whose rungs are all built from buildBase() — and buildBase() always re-applies query.where. So the ladder can drop a projection and can drop an ORDER BY, but it can never drop the clause that failed when the unresolvable column is in the WHERE: both rungs raise the same error and the method fell to return []. SqlDriver.count() runs a separate statement with no ladder at all, so the identical predicate threw. Measured on better-sqlite3, one seeded row: where { 'title.x': 'y' } gave find() 0 rows and NO error, while count() threw code: 'SQLITE_ERROR', status: undefined, message select count(*) as \count` from `task` where `title`.`x` = 'y' - no such column: title.x`.

A list view calls both halves, so one query produced an empty page from the rows half and a 500-shaped failure from the total half — and a caller reading only the rows got a silent empty page saying "no records exist" for what was really "your predicate never ran". That is the single most AI-legible failure to get wrong: an agent reads "no matching records" and writes its next query on that belief. The thrown half was no better — the dialect's own code, no status (an unclassified 5xx at the REST boundary rather than a caller mistake), and the statement's bound literals inlined in the message, the same predicate-text disclosure shape the driver's field-reference filter refusals had already been made to stop echoing (the full diagnostic goes to the server log, never the response).

Ruled by the maintainer on 2026-08-15: refuse BOTH halves with INVALID_FILTER / 400, naming the column. The envelope is not minted here — it is what every sibling refusal on this path already answers, required on both SQL drivers by cross-field-conformance-cases.ts and pinned by sql-driver-boolean-identity.test.ts and sql-driver-cross-field-conformance.test.ts — so what closes is a declared-vs-enforced gap, not a new posture. Recover-both was excluded by the ruling's own argument: dropping a WHERE returns rows the caller explicitly excluded, and the ladder's own premise — rows matter more than their order — is an argument about how rows are PRESENTED, which does not transfer to a predicate. The ladder KEEPS both of its recoveries — only the WHERE-failure terminal became a refusal.

Reach, stated rather than assumed: the refusal fires on the wordings the ladder has always recognised — SQLite (no such column: x) and Postgres (column "x" does not exist). MySQL spells it Unknown column 'x' in 'where clause', which neither arm matches, so on MySQL this condition still travels out as the raw dialect error; widening that predicate would also hand MySQL the ladder's recoveries it has never had, which is an accept-set change in the opposite direction and is filed separately.

Addendum 2026-08-16. The paragraph above is kept as the state at registration; this amends it. MySQL joined the one shared predicate, so the reach is now all three dialects this driver speaks, and a MySQL reader must NOT conclude the migration does not apply — it applies exactly as it does on SQLite and Postgres. Because that predicate serves both consumers at once, the accept set moved in BOTH directions on MySQL in one line, and both halves were ruled together (option A, maintainer, 2026-08-16; a split predicate — the envelope while withholding the recoveries — was considered and refused). (1) THE ENVELOPE: an unresolvable WHERE column now refuses with the same INVALID_FILTER / 400 naming the column, instead of travelling out as the raw ER_BAD_FIELD_ERROR with the statement's bound literals inlined — that disclosure shape closed on the last dialect that still had it. (2) THE RECOVERIES: MySQL also gained the ladder's projection and ORDER-BY recoveries it had never had, so an unresolvable column in a projection or an ORDER BY now returns recovered rows where it used to throw. The two halves arrive together because ER_BAD_FIELD_ERROR spells every clause position with one sentence — Unknown column 'x' in 'where clause' / 'field list' / 'order clause' — so all three ride one arm of the predicate; that is pinned as the ruled direction by the widened predicate sweep in sql-driver-unresolvable-where-column-refusal.test.ts. The widening can never drop a predicate: every ladder rung is rebuilt from buildBase(), which unconditionally re-applies query.where. Unchanged by the ruling: a DOTTED filter key is still classified per dialect (Postgres raises undefined_table, which neither arm matches), the axis owned by the dotted-filter verdict, which refuses a dotted key whose head is a relation, a formula or a plain column at the protocol and engine doors. The entry id, surface and prescription are unchanged — this is a text amendment, not a new migration.

This is a CODE-path API, not stored metadata, so — like engine-dotted-projection-refused and engine-find-formula-filter-refused — there is no sys_metadata row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which real column a mistyped filter key meant, and guessing one would answer with rows the caller never asked for. ADR-0112.

  • Done when: No saved report query.filter, flow condition, sharing/permission rule or hook filters on a name the queried object has no column for, and no report or dashboard groups by, or aggregates over, such a name. Reads, counts and aggregates complete with no INVALID_FILTER whose message says "names a column that object" or "names a column the database could not resolve", no INVALID_FIELD whose message says "has no column for, so the aggregate never ran", and no aggregate refused with DATABASE_ERROR / 500 because the object's table is absent. Where a filter key was a relationship traversal spelled as a dotted path, rewrite it against a column on the queried object — the driver never resolved such a path and answered [], so any list that looked correct under one was already showing nothing.
  • driver-sql-upsert-cross-row-identity-merge-refused — an upsertwith noconflictKeys— or naming the primary key — on a MySQL table that carries a non-primary UNIQUE key, indriver-sql(and itsTursoDriver/SqliteWasmDriversubclasses). It merged onto whichever UNIQUE key the row collided with, silently rewriting a DIFFERENT row; when that happens the write is now rolled back and the call refuses withVALIDATION_ERROR / 400 → name the business key you meant to merge on (conflictKeys), so the intent is checkable and the pre-flight can answer for it; or drop/rename the extra UNIQUE key so the primary key is the only thing a row can collide on; or run the object on SQLite / PostgreSQL, which compile ON CONFLICT (...) and honour the named arbiter. There is no spelling of "merge onto whatever key happens to collide" that was ever correct — the old behaviour rewrote a row the caller never identified
    • Why not automatic: MySQL's only merge statement is ON DUPLICATE KEY UPDATE, which carries NO conflict target: knex drops the named keys before the statement leaves the process, so the merge lands on whichever UNIQUE index the row collides with first. Two earlier pre-flight refusals closed the half where no unique index backed a caller-named target and the half where a rival unique key could absorb a caller-named one. This entry closes the residue those two left by construction: the conflictKeys-less call and the ['id'] call, which compile byte-identically and which no pre-flight can judge, because neither names anything.

Measured on live MySQL 8.0.46 through the same knex + mysql2 path upsert takes, email and tax_id both unique: true, NO conflictKeys at all: seeding {email:'d@b.com', tax_id:'T-9', title:'first'} inserted id iVvD35rMk4BIayYc, and {email:'e@b.com', tax_id:'T-9', title:'second'} then RESOLVED with no error — one row, the SEEDED one, its email rewritten d@b.com -> e@b.com. The id the caller was handed back was in no row at all. The identical pair on SQLite raises UNIQUE constraint failed: ….tax_id and leaves the seeded row untouched.

Ruled by the maintainer on 2026-08-15, as a contract principle rather than a MySQL detail: an upsert must never modify a row whose identity the caller did not supply and whose conflict key it did not name. Enforcement was delegated to the drivers lane with blanket refusal excluded by name — refusing every conflictKeys-less upsert on any table with a business unique key would refuse the platform's own lifecycle archiver. Measured before choosing: on this path the merge target is always the primary key, so EVERY non-primary UNIQUE key is a rival and "narrowed to tables carrying a rival key" and "every table with a business unique key" are the same set — the narrowing that made a pre-flight refusal proportionate for a caller-named target does not exist here.

So the enforcement is a post-hoc identity check instead, and it is exact rather than heuristic: id is insert-only on the merge path (made so once a merge on a non-primary conflict key was measured rewriting the existing row's primary key), so a row merged on the primary key always still carries the id the call supplied, and a row merged on any other key never does. Absence of that row after the statement is therefore a biconditional for "this landed on a row the caller never identified", which is why the refusal has no false positives. It runs inside a transaction with the statement — "never modify" is not satisfied by noticing afterwards — and only on MySQL tables that carry a rival UNIQUE key, so a table whose only key is its primary key keeps its single autocommitted round trip unchanged.

This is a CODE-path API, not stored metadata, so — like driver-sql-unresolvable-where-column-refused — there is no sys_metadata row for the D2 chain to rewrite and this entry is the notification channel. No mechanical rewrite exists: the platform cannot know which business key an unnamed merge meant, and guessing one would merge onto a row the caller never named, which is the defect. ADR-0112.

  • Done when: On MySQL deployments only. For every object whose rows are written with upsert and whose table carries a UNIQUE key besides the primary key, confirm the writer either supplies the id of the row it means to update or passes that business key as conflictKeys. Sweeps, imports and archival copies complete with no VALIDATION_ERROR whose message says "the merge landed on a row this call never identified". Where such a refusal appears, the old behaviour was silently overwriting an unrelated row on that table — audit the object for rows whose business key is correct but whose other columns belong to a different record, since no error was ever raised for those writes.
  • driver-turso-config-local-path-wasm-retired — @objectstack/driver-turso`'s published `TursoConfigSchema` — the Spec / Studio mirror of the turso connection config a host may render configuration UI from — keys `localPath` and `wasm → delete both keys. The embedded replica's local file is named by url (file:./replica.db) with syncUrl pointing at the remote primary, which is what the driver has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native bindings uses the remote arm (libsql:// / https://), which needs none
    • Why not automatic: ADR-0049 enforce-or-remove, ruled per key by the maintainer on 2026-09-06, once all three of this package's unread config keys had been measured: both keys were declared on the package schema with a describe promising behaviour ("Local file path for embedded replica", "Use WASM build for edge/browser environments") and were read by no code — the driver names the replica file via url, and no mechanism picks a WASM build. Forwarding localPath would have created a second way to say what url says; forwarding wasm would have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 conversion: @objectstack/spec's own turso contract (data/TursoConfig, strict) never declared either key, so no stack source or stored datasource row that passed the spec door can carry them, and a value that never did anything has no lossless rewrite — the key is deleted by hand. Both stay declared on the package schema as z.never() tombstones (the shape is a plain z.object, so a bare deletion would strip in silence) carrying this prescription. The third key the same measurement found, TursoDriverConfig.timeout, was forwarded rather than removed and needs no entry. ADR-0049, ADR-0087.
    • Done when: No TursoConfigSchema.parse(…) input spells localPath or wasm; authoring either fails to compile (input type never) and fails to parse with the prescription naming the key. A replica config that named its file only through url + syncUrl parses byte-identically to before, and every other declared key — url, authToken, encryptionKey, concurrency, syncUrl, sync, timeoutMs — keeps its bound, default and optionality.
  • driver-upsert-cross-organization-conflict-refused — IDataDriver upsert on SqlDriver, SqliteWasmDriver and TursoDriver (both faces): a tenant-scoped call whose conflict lands on a row of another organization, and the tenant column on the merge leg → a tenant-scoped upsert merges only into a row of the organization it writes under; a conflict anywhere else answers UNIQUE_VIOLATION / 409 and writes nothing, so handle it as the colliding insert it is from the caller's organization. To move a row between organizations, call the driver's update door on that row: an upsert keeps the stored row's organization on merge
    • Why not automatic: upsert resolves its conflict against the whole table, and the primary key and a unique: 'global' column are installation-wide (ADR-0120 D1). So the row a tenant-scoped call (options.tenantId on an object with a tenant column) collided with could belong to an organization the caller cannot read. The merge leg wrote every payload column except the insert-only ones onto that row, and the tenant column was not insert-only: the other organization's columns were overwritten and the row was re-parented to the caller's organization, with no error. That was the one driver door the tenant predicate did not reach (ADR-0131 D8). The merge leg is now fenced to rows whose stored tenant column equals the written one, for any conflict target, the primary key included. A conflict anywhere else, including a row with no organization, is refused with UNIQUE_VIOLATION / 409, the registered code a colliding insert gets, and the refusal names no organization. The fence is a predicate inside the merge statement on SQLite, PostgreSQL and the remote libSQL face. MySQL's merge statement takes no predicate, so there the statement and a read of the landed row run in one transaction (a savepoint inside a caller's transaction) and the read's failure rolls the write back. The tenant column also joined insertOnlyUpsertColumns, so an upsert with no tenant context keeps the organization of the row it merges into. Two things can break, and neither reaches the compiler, since the call signature is unchanged. Code that let a tenant-scoped upsert land on another organization's row now gets a refusal where it got a silent merge. Code that relied on a payload's tenant value to move a row on merge now finds the row where it was. ADR-0131 D8 / ADR-0087.
    • Done when: Every caller that upserts with a tenant context handles UNIQUE_VIOLATION / 409 as a colliding insert, and none expects a merge into a row its organization cannot read. No caller relies on an upsert payload's tenant value to change which organization owns a row; that move goes through the update door. Proven when a tenant-scoped upsert on a key another organization's row holds answers UNIQUE_VIOLATION and that row reads back unchanged, while the same call on a row of the caller's own organization merges as before. An upsert with no tenant context that merges into a row leaves the row's organization as it was.
  • element-data-source-and-object-block-filter-rule-array — Page-component dataSource.filter (ElementDataSourceSchema, the binding every data-bound element carries) and the filterprop of the fourobject-*blocks inComponentPropsMap—object-grid, object-metric, object-kanban, object-calendar(the FORM: the MongoDB-styleFilterConditionSchemarecord at the binding, and the accept-anythingz.unknown()at the four block doors, vs theViewFilterRule array) → z.array(ViewFilterRuleSchema) at all five doors — the rule array [{ field, operator, value }, ...] every other filter door in the map already carries (record:related_list, its Add-affordance picker, element:number, element:record_picker). A record-form filter { status: 'active' } becomes [{ field: 'status', operator: 'equals', value: 'active' }]; an operator object { status: { $ne: 'done' } } becomes [{ field: 'status', operator: 'not_equals', value: 'done' }]; several keys become several rules (they AND). An ObjectQL AST tuple array [['owner_id', '=', '{current_user_id}']] — which the z.unknown() block doors also took — becomes [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]; the value placeholders and date macros are unchanged. Legacy operator shorthands (eq, ne, gt, notIn, …) are accepted and normalized on parse. The dashboard widget filter (dashboard.zod.ts) is a different family, judged on its own, and is not moved by this entry; object-grid.defaultFilters is a different key and is not named by the ruling this entry records.
    • Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: a filter door takes the ViewFilterRule array rather than keeping a record-shaped exception every author and AI would have to remember) reached two more locations the ComponentPropsMap census could not see (ruled 2026-09-06, option A: converge the binding and the four block doors family-wide, under one entry, rather than record an exception). The binding-level dataSource.filter alone still said FilterConditionSchema: it refused the array the consumer's own pins author at that key, and element:record_picker carried two orthographies at two keys (properties.filter the rule array, dataSource.filter the record) resolved through one ?? in the renderer — the shape in which a dropped or misread filter returns the wrong rows without an error. The four object-* doors said z.unknown(): a read-point record derived from the renderers on 2026-08-13, when the object-* blocks first got props schemas in the map, twelve days before the ruling, not an exception to it — so an author following the showcase wrote the record and an author following the manifest wrote an array, and each got a silent success receipt while the html tier already declared array for the grid and the metric. The record's $and / $or / $not keys were misread by every gate block anyway (the console's filter converter had no branch for them), so the exception would have preserved a capability the consumer does not honour. Sequenced measurement-first, as the family had to be: at the objectui pin a472b07 the object-metric aggregate path posted an array where that POST /analytics/query refused with 400 on every array form, so the converge was parked behind a bump of that pin; at the pin this repo builds against (53ded82b) the adapter lowers an authored array through translateFilterArray and the spec's own parseFilterAST sink before the wire, ObjectGrid.tsx lowers a rule array through toFilterNode, ObjectKanban.tsx / ObjectCalendar.tsx hand it verbatim to $filter where convertQueryParams lowers it, and the binding's composition seam AND-combines it with the named view's rules through mergeFilterNodes. The ruled migration check ran with the change: the in-repo sweep found four spec test fixtures at the binding (page.test.ts, all record form), five showcase authors at the block doors (my-work.page.ts, index.ts: four records on object-metric, one AST tuple array on object-grid) and three lint fixtures — every one rewritten to the rule array in the same change, and zero outside those files; this entry carries the prescription for authors outside the repo. Metadata AT REST: the mappable part of the table above is a D2 conversion, page-component-filter-record-to-rule-array (ruled 2026-09-12, option B: convert what maps losslessly and name what does not, rather than leave every stored row to its next save or flatten combinators), so os migrate meta --stored (the pass over a deployment's sys_metadata rows) rewrites a stored page whose filter is a flat record, an operator object whose operators the rule vocabulary spells, several such keys, or a single-level AST tuple array, and every stored-row read replays the same rewrite until it does. It is retired from the load path: an author writing the record form is still refused at the filter door. ⚠️ A filter carrying $and / $or / $not is left exactly as stored — the rule array only ANDs, and flattening a combinator changes which rows the page selects — and so is any filter with a part that has no lossless rule spelling: a null value (where a block queries an object the renderer skips that key, so it constrains nothing, and where its rows are inline it selects the rows whose value is null — no one rule keeps both, so the TODO names the is_null rule for the rows with no value and leaves which rows to select to the author), an operator such as $null / $exists or an AST like, an array or object comparand in equality position, or an AST and / or group. None of this depends on where a block's rows come from: a filter on a component whose rows are inline (data: { provider: 'value' } or staticData) — the binding's included — is rewritten or left exactly as it would be on a block that queries an object, because the object-map, object-tree, object-calendar and object-gantt blocks of the objectui version this release pins match a rule array against those rows and select the rows the stored form selected. A row left as stored keeps loading unchanged (applyConversionsToStoredItem replays the chain without validating, by its own contract), and its filter door refuses the form: at dataSource.filter on the page's next save; at a block's properties.filter — like properties.defaultFilters, a key of the open properties bag — only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there. For a combinator record that refusal names the combinator and says why no rule spells it. os migrate meta --stored lists each such filter as a TODO under its row, naming the block and what blocks the rewrite (a value that is not a record or AST form at all — a bare string or number one of the former z.unknown() doors took — is neither converted nor reported, and a row carrying nothing else reads as already on protocol); a row whose only finding is such a TODO is reported skipped, and the run's exit code does not change for it.
    • Done when: ElementDataSourceSchema.safeParse({ object, filter: [{ field: 'status', operator: 'equals', value: 'active' }] }) succeeds and the parsed filter is the same rule array; ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' | 'object-calendar'].safeParse({ filter: <that array> }) raises no issue at filter; a record-form filter: { status: 'active' } is refused at the filter path of all five doors (invalid_type, expected array), and an AST tuple array is refused at filter.0 (expected object). No filter door in ComponentPropsMap accepts the record any more (the twin of the census pin that asks whether any filter door still refuses the array). At runtime each block and the binding select exactly the rows the array selects — the same filter a list view renders — including the object-metric aggregate tile, whose analytics where is the lowered condition. Downstream (objectui, after a released spec version reaches the pin): the seventeen dataSource.filter test authors at the pin (fifteen tuple arrays, two records) become off-spec fixtures and ElementDataSourceConfig.filter's "three shapes" note narrows — objectui cards filed by the seat, not blocked on here.
  • element-filter-and-form-node-refused — page.component.element:filter / page.component.element:form — the bare component node itself, left standing by the element-filter-removedandelement-form-removed conversions after they strip its properties → Delete the component node. element:filter → a list surface owns its own filtering: use a view's userFilters quick-filter bar or the list toolbar's filter builder. element:form → the object-bound object-form block, which is rendered, designer-publishable and carries the same intent (objectName, fields, mode, submitText). Nothing is placed where the node was unless the page needs it — which region keeps its layout is the judgment this step delegates
    • Why not automatic: Both elements were retired whole at element grain (ADR-0049 enforce-or-remove): no renderer for either ever shipped in objectui, framework or cloud, so every authorable key was a capability claim nothing kept. The conversions are mechanical where they can be — they strip all twelve keys losslessly — and stop at the node, because removing an authored page node changes the LAYOUT of a page the author composed, and a conversion cannot know whether the region should close up, hold a replacement, or keep its slot. That residue is no longer inert: both names are members of RETIRED_PAGE_COMPONENT_TYPES, so PageComponentSchema.type refuses them by name, and a stack that replays the chain and stops there is schema-INVALID. Mechanical where it can be, delegated where it cannot — this entry is the delegation, in writing
    • Done when: No element:filter and no element:form component remains in any page — regions, named slots and nested containers alike (the conversions walk all three, so every place they stripped properties is a place a bare node can be sitting). os validate is clean: the refusal is reported at the node's type path with params.retiredComponentType naming the element, so a remaining node is named individually rather than as one page-level failure. Replaying the same 17 → 18 chain over the edited source then reports the migrated stack schema-valid — schemaValid: true in --json, and the run closes with the schema-valid line rather than the manual-changes warning
  • element-input-target-variable-retired — page.component.element:text_input.targetVariable / page.component.element:record_picker.targetVariable — the declarative binding hint on the two input elements → Declare the binding on the page variable instead: a variables[] entry whose source is the input component id. That reverse lookup is the one binding the renderer has ever honoured; the variable name is the author's choice, and targetVariable named it from the wrong end.
    • Why not automatic: The D2 conversion element-input-target-variable-removed deletes targetVariable from every text-input and record-picker component, and the delete is lossless: no renderer, hook or runtime ever read the key, so an input authored with it and without a matching variables[].source wrote nothing, with a success receipt and no diagnostic. What the delete cannot do is restore the intent. An author who wrote targetVariable: 'contact_email' meant that input to feed that variable, and after the strip the page is exactly as unbound as it always was — now without even the hint that says so. Whether the variable exists, whether its source already names this component, and whether anything downstream (a flow input, a filter, a visibility predicate) reads it are facts about the author's page that no conversion can see, so the binding is delegated rather than invented.
    • Done when: For every element:text_input and element:record_picker component that carried targetVariable: either the page declares a variable whose source equals the component id, or the author has decided the input needs no binding. With the binding declared, typing into the input (or picking a record) and then reading the variable — from whatever consumes it on the page — returns the value entered. No component authors targetVariable; the parse refuses it by name.
  • element-number-filter-rule-array — ``element:numbercomponent props —filter(the FORM: the MongoDB-styleFilterConditionSchemarecord vs theViewFilterRule array) → z.array(ViewFilterRuleSchema) — the rule array [{ field, operator, value }, ...] every other filter input in ComponentPropsMap already declares (record:related_list and its Add-affordance picker). A record-form filter { status: 'won' } becomes [{ field: 'status', operator: 'equals', value: 'won' }]; an operator object { amount: { $gt: 100 } } becomes [{ field: 'amount', operator: 'greater_than', value: 100 }]; several keys become several rules (they AND). Legacy operator shorthands (eq, gt, notIn, …) are accepted and normalized on parse
    • Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: align the element to the ViewFilterRule array rather than keep it the record-shaped exception). ComponentPropsMap['element:number'].filter was the one filter input in the map declared as the MongoDB-style record (FilterConditionSchema) while its siblings declared the ViewFilterRule array, so the filter a list view stores and renders was refused by the KPI element beside it, and the objectui parity gate had to carry a reasoned exemption to look away. The convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): the console adapter was changed so ObjectStackAdapter.aggregate() runs the same translateFilterArray its find() path runs, and the objectui pin carrying it was re-measured before this entry moved — but that measurement named the wrong hop, and the runtime route's refusal of the array corrects it here. translateFilterArray yields AST tuples, which are still a FilterArray — input-only sugar — so the real path is: authored array → translateFilterArray → lowered by parseFilterAST (@objectstack/spec/data, the single sink the FilterArray docblock names, since the maintainer's 2026-08-04 ruling C declared the array input-only sugar with one lowering seam) in the adapter, BEFORE the wire → a FilterCondition on the body. The hop that decides it is the runtime route POST /analytics/query, which parses where with AnalyticsQueryRequestSchema — a FilterCondition and nothing else — so an un-lowered array is refused there before any service code runs. lowerAnalyticsWhere (service-analytics), where that earlier measurement stopped, is the IN-PROCESS door (added when an array where was found silently dropped on the analytics path) for callers reaching analyticsService.query directly, not the wire's; it too still refuses a RAW rule-object array by design. The adapter-side lowering lands in the console's own repository. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/, spec fixtures) found ONE element:number author writing a record-form filter — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo.
    • Done when: ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: 'status', operator: 'equals', value: 'won' }] }) succeeds and the parsed filter is the same rule array; a record-form filter: { status: 'won' } is refused at the filter path (invalid_type, expected array). At runtime the element renders its aggregate on an analytics-capable deployment with the array filter applied — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the element:number.filter:array entry in OFF_SPEC_ARM_EXEMPTIONS (registry-inputs-spec-parity.test.ts) becomes deletable, which is what closes the console-side half of this convergence.
  • element-record-picker-filter-rule-array — ``element:record_pickercomponent props —filter(the FORM: the MongoDB-styleFilterConditionSchemarecord vs theViewFilterRule array) → z.array(ViewFilterRuleSchema) — the rule array [{ field, operator, value }, ...] the map's array-declared filter doors already carry (record:related_list, its nested Add-affordance picker, element:number; the four object-* blocks declare filter as z.unknown(), a gap measured on its own). A record-form filter { status: 'active' } becomes [{ field: 'status', operator: 'equals', value: 'active' }]; an operator object { amount: { $gt: 100 } } becomes [{ field: 'amount', operator: 'greater_than', value: 100 }]; several keys become several rules (they AND). Legacy operator shorthands (eq, gt, notIn, …) are accepted and normalized on parse. The binding-level dataSource.filter on the same node is a different key (ElementDataSourceSchema) and is not moved by this entry
    • Why not automatic: One filter orthography platform-wide (the maintainer's 2026-08-25 ruling, option B: every filter door takes the ViewFilterRule array rather than keeping record-shaped exceptions). ComponentPropsMap['element:record_picker'].filter was the LAST filter input in the map still declared as the MongoDB-style record (FilterConditionSchema) after element:number converged: the three array-declared doors (record:related_list, its nested Add-affordance picker, element:number) carried the ViewFilterRule array and the four object-* doors declare z.unknown(), so the filter a list view stores and renders was refused by the picker beside them, and a lone holdout is the state where the next author copies the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 Option-A ordering ruling: measure the consumer's read path before the contract moves): at the objectui pin 00d3f09c the renderer hands filter to query.$filter and calls adapter.find() (components/src/renderers/basic/record-picker.tsx); ObjectStackAdapter.convertQueryParams lowers an ARRAY $filter through translateFilterArray into filter AST tuples (data-objectstack/src/index.ts), the same door every list view's stored rule array already takes, and the engine lowers the tuples before the driver (engine-filter-array-lowering.test.ts); nothing on that path parses properties against the installed spec. The pin and objectui main (f7cf7e8) are byte-identical on every read-path file. The ruled migration check ran with the change: the sweep of first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) found ONE element:record_picker author writing a record-form filter — a spec test fixture, rewritten to the array form in the same change — and zero outside the spec package; this entry carries the prescription for authors outside the repo.
    • Done when: ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: 'status', operator: 'equals', value: 'active' }] }) succeeds and the parsed filter is the same rule array; a record-form filter: { status: 'active' } is refused at the filter path (invalid_type, expected array). At runtime the picker offers exactly the rows the array selects — the same filter a list view renders. Downstream (objectui, after a released spec version reaches the pin): the registry's inputs.filter entry for element:record_picker (type: 'object', record-picker.tsx) flips to the array arm and the record-picker-inputs-spec-parity.test.ts pins that assert the record form follow — a console-side change filed in the objectui repository, blocked on that release.
  • element-text-variant-heading-subheading-retired — page components of type element:text — properties.variant authored as heading or subheading (ElementTextPropsSchema.variant) → one of the nine values ui:text publishes: h1-h6, body, caption or overline. 'heading' → 'h2' and 'subheading' → 'h3' (the heading element each one always rendered), or the level the page outline means
    • Why not automatic: The ruling converged element:text on the vocabulary ui:text already publishes, because a heading is a document level, not a text style: heading and subheading named a style and left the renderer to pick a level. It landed in two releases so authors outside this repository could move first — 17.5.0 added the nine and refused nothing, and 17.6.0 was a full release in which both vocabularies parsed. The D2 conversion element-text-variant-heading-levels makes the ruled edit: heading → h2, subheading → h3. That keeps the heading element (the renderer drew heading as an h2 element and subheading as an h3 element), so the document outline a screen reader walks is unchanged, but not the size: heading drew in the h3 style and subheading in a medium-weight small heading style, and h2 / h3 draw their own, larger styles. Whether the page wanted that level is the author's call — a heading placed for its size rather than its place in the outline may want a deeper level. Nothing is dropped at rest: a stored page replays the rewrite at rehydration; a page component's properties is not parsed on the save path, and the component-props gate reports an old spelling as an advisory component-props-invalid finding, carrying the prescription, on os validate, os build and os lint. ADR-0087
    • Done when: No element:text page component carries variant heading or subheading; os validate reports no component-props-invalid finding under properties.variant for these blocks. For each rewritten block, open the page and check the heading: it renders the same heading element as before, in its level's style. Where the old, smaller look mattered more than the level, pick the level whose style you want and confirm the outline still reads in order. A block that omits variant still renders as body.
  • engine-dotted-filter-refused — a where / filter whose KEY is a dotted path with a relation, virtual-formula or plain-scalar head ({"project_id.name": …}, {"is_open.x": …}, {"title.x": …}) — 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 queried object (a stored field, written when the source changes) and filter that — the same remedy, in the same words, the SORT axis prescribes when it refuses the dotted spelling. To read a related column, $expand is unchanged; to CONDITION on one, the stored denormalised field is the supported shape. A dotted path into a structured/JSON field ({"address.city": …}) is NOT refused and keeps its current per-driver behaviour
    • Why not automatic: FILTER was the last of the four query axes with no verdict for a dotted name: SORT refuses it, PROJECTION refuses it at both doors, and the FILTER gates judged a key on its HEAD SEGMENT only — so where {"project_id.name": "Apollo"} cleared the unknown-name check (which refuses a key naming no field of the object) because project_id is a real field, reached a driver that cannot serve the path, and answered 200 with zero rows. The formula verdict deliberately skipped dotted keys, so the axis answered one unserviceable intent two ways by spelling: {is_open: true} was refused while {"is_open.x": true} rode through.

Measured across all THREE drivers before ruling: relation-head, formula-head, system-column-head and plain-scalar-head dotted filters return ZERO rows on driver-memory, driver-sql AND driver-mongodb, each under an ordinary 200 indistinguishable from an empty table. There is no working capability for this refusal to remove: an ObjectStack lookup stores the related record's SCALAR id (SQL: a string column plus FK; Mongo: a single-key index on a scalar), while Mongo's dotted paths traverse EMBEDDED DOCUMENTS — so the spelling is well-formed Mongo that matches nothing. On driver-sql, knex reads the dot as a table qualifier and emits a column no dialect can resolve; find() falls into the unknown-column recovery ladder and returns [] silently (the same measurement caught the list and count halves answering that query two different ways, a divergence with its own entry, driver-sql-unresolvable-where-column-refused, that now refuses it on both).

Both doors now refuse the three measured-dead head classes with 400 INVALID_FIELD, naming the whole offending key exactly as the caller wrote it and carrying the remedy sentence — no new mechanism, no new error class, per the maintainer's ruling. Both judge the head by the SAME @objectstack/spec/data classification (classifyDottedFilterHead), the one-source move the formula verdict made with isVirtualSearchField, so the doors cannot drift into answering one spelling two ways. Precedence mirrors the sort axis, verdict for verdict: unknown > dotted > unmaterializable.

DELIBERATELY UNJUDGED, per the same ruling: a dotted path whose head is a structured/JSON field (address.city) — the one spelling the drivers genuinely disagree on (live on memory and mongodb, 2 rows in the measurement; silently empty on sql). Refusing it for symmetry would delete a working capability on two of three backends; declaring JSON-path filtering a capability (supports) waits for a real consumer. Array-valued heads (multiple: true, tag types) and file heads are unjudged for the same measured reason. The nested-relation OBJECT form { owner: { region: "NA" } } is untouched: the refusal targets the dotted-STRING spelling alone.

This is a CODE-path API, not stored metadata, so — like engine-find-formula-filter-refused and engine-dotted-projection-refused one step down — there is no sys_metadata row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot invent the stored column the remedy prescribes, and it must not join or post-filter instead — the drivers have already applied limit/offset, so any post-hoc predicate would filter an arbitrary page.

AUTHOR-REACHABLE SURFACES: a saved report's query.filter (sys_saved_report) is forwarded VERBATIM into engine.find by plugin-reports, bypassing the ingress; flow node config.filter and dashboard widget filters are author-written the same way. A dotted filter path is exactly what an AI author writes by analogy with $expand, projection spellings and SQL joins — and it used to answer an empty list indistinguishable from "no matching records", often with the related value reading correctly in the very same response. It now fails loudly, with the remedy in the message. Registered on the same inherited ruling as its siblings — the SORT-axis engine refusal was registered in this ledger although no stored row needs rewriting, re-affirmed for the FILTER axis on 2026-08-13. ADR-0112.

  • Done when: No filter key is a dotted path whose head is a relation (lookup / master_detail / user / tree), a formula, or a plain scalar — grep your saved report definitions (sys_saved_report.query.filter), flow node config.filter, dashboard widget filters and view filters for keys containing a dot, and for each either denormalise the related value onto a stored field of the queried object, or (for a relation id test) filter the head field itself ({"project_id": <id>}). A dotted path into a structured/JSON field ({"address.city": …}) needs NO action — it is deliberately not judged. Reads complete with no INVALID_FIELD naming a dotted filter key, at either door.
  • epoch-instant-keys-renamed — four epoch-instant keys whose name carried no unit: WebSocketEvent.timestamp, SimplePresenceState.lastSeen, KernelContext.startTime (inherited by TenantRuntimeContext) and HealthStatus.timestamp → the same instants named for what they mark and typed with the new shared EpochMs schema (shared/epoch.zod.ts): occurredAt, lastSeenAt, startedAt and checkedAt. The VALUE is unchanged in every case — still milliseconds since the Unix epoch, still Date.now(). Only the key name and the declared schema move
    • Why not automatic: Maintainer ruling B (2026-09-05, on the population the 2026-09-02 rule reaches): a duration-shaped z.number() carries its unit in the key NAME, minus two structural classes declared ON THE SCHEMA rather than in a gate ledger. Epoch instants are the first class. They read to the rule exactly like an offending duration — a bare name plus a describe that says "milliseconds" — but renaming them the way the rule prescribes would resolve the wrong confusion: measured on this package own authorable surface, all 51 distinct keys ending in Ms are durations (timeoutMs, backoffMs, latencyMs, uptimeMs) and all 51 distinct keys ending in At are instants (createdAt, expiresAt, lastUsedAt). Spelling an instant with the Ms suffix would move it INTO the duration family. So the exemption is a declaration on the contract: the value becomes EpochMs, which states the epoch-millisecond unit once, and the key takes this package established At convention. Two of the six instants ruling B names (ServiceMetadata.registeredAt and ScopeInfo.createdAt) were already correctly named and only changed schema, so they are not retirements and appear in no table. A SEMANTIC entry rather than a D2 conversion because all four keys are RUNTIME-EMITTED — a WebSocket event and a presence payload are wire messages, a kernel context is constructed by host code at boot, a health report is emitted by the startup orchestrator — so none is ever stored as a sys_metadata row and the conversion chain has no seam that would see one. That is the same disposition kernel/KernelContext:previewMode already carries on one of these very defs, and ruling B prescribes it explicitly: an ADR-0087 conversion where the key is authorable, a semantic entry where it is runtime-emitted. ADR-0087.
    • Done when: No producer emits the old key and no consumer reads it. All four are tombstoned with retiredKey(), so each fails tsc at the construction site (the key types never) and fails the parse with the rename prescription. Concretely, check four places. (1) Code building a WebSocketEvent: rename timestamp to occurredAt. (2) Code building a SimplePresenceState: rename lastSeen to lastSeenAt — and note that the neighbouring PresenceState.lastSeen (api/realtime-shared.zod.ts) is a DIFFERENT key holding an ISO-8601 datetime string, which is untouched and must not be renamed with it. (3) Host boot code composing a KernelContext or a TenantRuntimeContext: rename startTime to startedAt. (4) Code building a kernel HealthStatus: rename timestamp to checkedAt. In every case the value is carried across unchanged. One behavioural note: WebSocketEvent.timestamp and SimplePresenceState.lastSeen were declared z.number() with no integer constraint and EpochMs is z.number().int(), so a fractional epoch that used to parse is now refused at those two sites — a tightening, and Date.now() has always satisfied it.
  • esignature-config-deadline-keys-retired — e-signature deadline keys: ESignatureConfig.expirationDays/reminderDays (document.eSignature.expirationDays/document.eSignature.reminderDays) → nothing to re-declare — delete the keys. No e-signature engine exists on the platform: no signature request is sent, expired or reminded by any layer, so there is no live mechanism to declare an expiry window or a reminder interval to. ESignatureConfig itself stays (provider / enabled / signers), unchanged
    • Why not automatic: ADR-0049 enforce-or-remove; the 2026-09-02 ruling on the unread deadline keys held this pair on one condition — "no roadmap ⇒ they retire with the other three families" — and the maintainer answered it on 2026-09-05 (no roadmapped e-signature consumer), so the ruling's own branch resolves to retirement. Two day-shaped keys sat on the published authorable surface (authorable-surface/data.json) and in the generated reference docs — an author could write expirationDays: 30 and reasonably expect a signature request to lapse after thirty days — and were read by NOTHING: the reader census over every package outside packages/spec (tests and changelogs excluded), over examples/** and skills/**, and over objectui at the pinned sha returned zero hits for expirationDays, reminderDays, eSignature and the ESignatureConfig names, with a lit control inside packages/spec. Both carried defaults (30 days, 7 days) that were materialized into every parsed configuration without ever being consulted. cloud and real customer configurations are UNMEASURED. Why D3 semantic and not a D2 conversion: DocumentSchema is not a stack collection member and document is no metadata type, so the chain has no seam that would ever see one (the kernel/MetadataPluginConfig:additionalTypes precedent); the prescription reaches authors through the retiredKey() tombstones (tsc + the parse) and this entry.
    • Done when: No ESignatureConfig literal — standalone or nested as Document.eSignature — carries expirationDays or reminderDays. TypeScript authors get the refusal at compile time (each key is typed never); a value reaching the parse is refused with the prescription (invalid_type at the path of the key, on the base schema and through the DocumentSchema.eSignature carrier). Parsed configurations no longer carry the two former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour.
  • evaluated-expression-slots-source-required — every EVALUATED expression slot in the spec — the 34 declaring positions of the census of engine-evaluated slots outside the flow ledger that survive into this major, enumerated by identity and not by a name scan: the formula Field.expression; the predicate keys visibleWhen / visibleOn / readonlyWhen / requiredWhen / visibility / disabledWhen / visible / disabled / condition / when, on Field, SelectOption, InlineGridColumn, ScriptValidation, CrossFieldValidation, ConditionalValidation, Hook, ObjectFieldGroup, RowCrudActionOverride, CriteriaSharingRule, PluginPermission.filter, MultiVersionSupport routing, Action and ActionParam (including each param option), BaseNavItem, BulkActionDef, PageComponent, PageTabs items, RecordAlert, ListViewShape, FormFieldBase, FormSection and the settings-manifest Specifier and manifest visible — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it. ⚠️ The census this entry was written against counted 36, and the two that are deliberately absent here are the ServiceLevelIndicator successCriteria and TraceSamplingConfig composite condition expression arms. They are not lost: they were RETIRED OUTRIGHT in this same unpublished major by the observability-cel-predicates-retired entry of this step, under ADR-0049 enforce-or-remove, because nothing evaluated either. Both entries first ship together, so an upgrader never meets those two slots under THIS rule — the composite of the two changes is the retirement alone, and stating the narrowing for a slot that no longer accepts an expression at all would send the upgrader to author one. That absorption is the only reason the count here is not the census figure of 36. The published TypeScript interface RowCrudPredicates narrows with the two slots it mirrors. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on any of these metadata types, and a row already sitting in sys_metadata → a non-blank source. ⭐ For an ast-only envelope the recovery is MECHANICAL and lossless for cel, which is the one dialect in this population that has an AST at all: printCelAst(ast) from @objectstack/formula (shipped with this narrowing, the inverse of parseCelToAst) prints the AST back to surface syntax, and the recovered string is the new source — keep the ast beside it if you want, an ast BESIDE a string source is untouched and stays admitted everywhere. ⚠️ Lossless is about MEANING, not bytes: the printer re-renders from the parse tree, so single-quoted literals come back double-quoted (record.p == 'x' → record.p == "x") and parentheses the parser dropped do not come back. It answers null — never a guess — for an ast it cannot round-trip through the platform's own bounded parser; that null is the hand-migration case. For a BLANK source there is nothing to print from, so this entry delegates the judgment, and it is the same fork the flow-edge condition narrowing named: author the predicate the slot was meant to carry, or REMOVE the key entirely. ⚠️ Those two are not interchangeable and the choice is per slot, not per file. A refused predicate reached its evaluator and faulted, and what the fault DID differs by slot: on the fail-closed ones (ObjectFieldGroup.visibleWhen, RowCrudActionOverride.visibleWhen, BulkActionDef.visible, the two settings-manifest visible slots) it HID or EXCLUDED, so removing the key REVEALS what was hidden; on the fail-soft ones (the rest) it left the gate open, so removing the key preserves what was happening. Removing to clear the refusal is therefore safe on one half of the population and a silent disclosure on the other
    • Why not automatic: Maintainer ruling 2026-09-12, option A: every engine-evaluated expression slot requires a non-blank source, with an ADR-0087 migration path, while the persistence contract stays wide. The rule first set for the flow-node ledger, and then carried to FlowEdgeSchema.condition, generalises to every other slot an engine evaluates. Each of those slots now composes EvaluatedExpressionInputSchema instead of ExpressionInputSchema, so an evaluated slot is held to what the engine can actually run. The engine reads source alone (cel-engine.ts evaluate: "AST-only evaluation not yet supported; persist source"), so both refused spellings landed in its fault arm on every release that carried them — and measured at the chokepoint, the engine never silently SUCCEEDS on either: it returns a parse fault, and what happened next was decided entirely by the slot's fail policy. Nothing between the author's keystroke and that fault said a word — the authoring lint validateVisibilityPredicates measured 0 findings on an ast-only envelope and 0 on a blank source, against two control legs that each measured 1. The refusal is one rule with one sentence, EVALUATED_EXPRESSION_SOURCE_REQUIRED. ⚠️ ExpressionSchema / ExpressionInputSchema are deliberately NOT narrowed and neither is their alias PredicateInputSchema: they are the PERSISTENCE contract (source OR ast) and stay wide by the same ruling's item 2. The narrowing is at the evaluated slots only. ⚠️ Why this is a D3 entry and not a D2 conversion, even though a printer now exists. The conversion layer lives in packages/spec, which is dependency-free by Prime Directive #2 and carries no engine — packages/formula's own normalize.ts header states the same boundary from the other side ("Spec layer cannot do step 2 because it must remain dependency-free; this package owns the engine import"). A conversion that had to call the CEL printer could not live where conversions live, and a conversion that guessed without one would be the platform inventing a predicate. So the printer ships as a named, tested export the migration PRESCRIBES, and the judgment the printer cannot make — a blank source, an opaque ast that does not round-trip, any future dialect with no printer — stays here as the structured TODO, naming the object, field and slot. ⚠️ And for a row ALREADY STORED the consequence is wider than the key. applyConversionsToStoredItem replays the conversion chain on rehydration, but no conversion can supply a source that was never written, so a stored row carrying either spelling now fails its schema parse at the seam that loads it rather than parsing and faulting later. That is the intended direction — the refusal moves from run time, where it was invisible on the fail-soft slots and destructive on the fail-closed ones, to load time, where it names the row. ADR-0087, ADR-0058, ADR-0049.
    • Done when: Sweep every authored metadata source and every sys_metadata row for the two spellings on the slots named in surface: an expression envelope with no source key, and a source (or bare-string shorthand) that is empty after trimming. ⚠️ Sweep by SLOT, not by key name — visible is on this list for actions, action params, nav items, bulk actions, record alerts and settings manifests, and is NOT an expression slot elsewhere; and ONE of the positions is a union member, RecordAlertProps.visible, whose sibling arm is untouched: it still takes a boolean literal, so a boolean there is not a hit. ⚠️ The two OTHER union members the census listed — ServiceLevelIndicator.successCriteria and TraceSamplingConfig.composite[].condition — are deliberately NOT on this sweep, because their expression arms were retired outright in this same major (see surface). Sweep those two under observability-cel-predicates-retired instead, whose instruction is the opposite of this one: there, an expression is not repaired, it is replaced by the structured shape or moved out of application metadata. For each hit: if it carries an ast, run printCelAst(ast); a string result IS the migration and needs no judgment beyond reading it back. A null result, or a blank source, is the hand-migration case — decide per the replacement note whether the slot was meant to carry a predicate (author the source) or to be ungated (remove the key), and ⛔ do not default to removal on a fail-closed slot, where removal reveals rather than preserves. Two proofs. (1) objectstack validate is clean on a stack authored in config files: each offender is located by path with the EVALUATED_EXPRESSION_SOURCE_REQUIRED sentence — one invalid_union issue at the slot for an ast-only envelope or a blank bare string, one custom issue at source for a blank source inside an envelope. There is no CLI verb that lowers a stored row back into a config file, so this proof does not reach metadata that exists only in sys_metadata. (2) For stored rows, load the tenant and confirm every metadata item of the affected types still rehydrates: a row carrying either spelling now fails its parse at the load seam and is reported there, naming the object, the field and the slot. A row whose every evaluated slot carries a non-blank source parses byte-identically to before — the narrowing removes accepted shapes and adds none.
  • event-name-schema-retired — ``EventNameSchemaand itsEventName type (@objectstack/spec/shared, shared/identifiers.zod.ts), and the dot-notation grammar it imposed on its only three binding fields: EventTypeDefinitionSchema.nameandEventSchema.name (kernel/events/core.zod.ts) and EventMessageSchema.eventName (api/websocket.zod.ts). → (removed — no replacement grammar layer. The three binding fields stay and widen to plain z.string(); the event vocabulary the platform actually checks is the closed literal enums DataEventType / BulkDataEventType (@objectstack/spec/api, api/events.zod.ts), which stand as the only event-name contract. A caller that imported EventNameSchema for standalone validation deletes the import; if it was validating platform event names, it parses through the enums instead.)
    • Why not automatic: Maintainer ruling 2026-09-01 (director decision batch C, verbatim 「同意」: retire) — ADR-0049 enforce-or-remove. The schema presented itself as the platform's event-name grammar while nothing that runs consumed its three binding schemas, and the closed enums that do the real checking never referenced it. The event surface is platform-defined, not author-extensible, so a grammar layer for a hypothetical extension surface is a trap, not a reserve: a generator satisfying EventNameSchema has satisfied nothing the platform will check, while one emitting outside the closed enums is refused by a rule the identifier file never mentioned.
    • Done when: No code imports EventNameSchema or EventName from @objectstack/spec/shared (TS2305 after upgrade); EventTypeDefinitionSchema.name, EventSchema.name and EventMessageSchema.eventName parse as plain strings (the accept set at those three fields widens — every previously valid document stays valid, so no source rewrite ships and objectstack migrate meta has nothing to visit); DataEventType / BulkDataEventType are byte-for-byte untouched; WebSocketEventSchema.channel remains a deliberate bare z.string() (the ruling adds no constraint there); the shared/EventName def key leaves json-schema.manifest/shared.json in the same change that registers this entry.
  • execution-step-iteration-single-valued — ``ExecutionStepLog.iterationon a step whoseregionKindisparallel-branch— the per-step records underExecutionLog.steps, as the automation run endpoints return them — and the new optional ExecutionStepLog.branch key → Read the parallel branch index from branch. iteration is now single-valued: the zero-based iteration of the enclosing loop, carried through any nesting, so a branch step of a parallel node that sits inside a loop body carries BOTH keys — iteration for the row and branch for the branch. A consumer that grouped or labelled steps by iteration under regionKind: parallel-branch moves that read to branch; a consumer reading iteration on loop-body, try or catch steps changes nothing.
    • Why not automatic: The key was declared as the zero-based loop iteration OR the parallel branch index of the enclosing region — one field, two meanings, told apart only by reading regionKind first. The engine tagged each step with its innermost region only, so for a parallel node inside a loop body every branch step recorded the branch index and no step of that branch recorded the loop iteration: a per-row failure inside a branch was attributable to a branch, never to the row the sweep was processing. The sibling try/catch rule had already settled the containment case — a try/catch region has no index of its own, so it carries the loop iteration — and deliberately left parallel open, because there the two indexes genuinely compete for one field. The maintainer ruling of 2026-09-03 took option A: iteration always means the enclosing loop iteration and the branch index moves to its own optional key, so a reader no longer has to branch on regionKind to know which number it holds, and getting that wrong no longer silently books a failure against the wrong row. Option B — keep the overload and add a second index whose presence depends on nesting shape — was not taken. This is not a mechanical conversion: a step record written before this change carries iteration under parallel-branch with the branch-index meaning, and only its producer knows whether the parallel node sat inside a loop. The measured corpus held zero loop { parallel } nestings and one consumer reading the key — a grouping key in the objectui flow-runs panel — so the migration is a consumer-side read move, not a data rewrite. The engine tagger that writes both keys follows this contract change as its own card; until it lands, branch is declared and unwritten, and iteration on a parallel-branch step written by an older engine still holds the branch index.
    • Done when: No consumer reads iteration as a branch index: every read of a parallel-branch step's index goes through branch, and every read of the enclosing loop iteration goes through iteration regardless of regionKind. A step record carrying regionKind: parallel-branch, iteration: 3, branch: 1 parses under ExecutionStepLogSchema with both numbers intact, and a negative or fractional branch is refused at the branch path. A record written before the engine follow-on carries no branch key; treat its iteration under parallel-branch as the legacy branch index only when the record predates the engine build that writes branch.
  • export-job-family-retired — the export-job API family, retired whole: the twelve defs api/ExportJobStatus, api/CreateExportJobRequest, api/CreateExportJobResponse, api/ExportJobProgress, api/ScheduledExport, api/GetExportJobDownloadRequest, api/GetExportJobDownloadResponse, api/ListExportJobsRequest, api/ExportJobSummary, api/ListExportJobsResponse, api/ScheduleExportRequest and api/ScheduleExportResponse with every name api/export.zod.ts exported for them from @objectstack/spec/api (the Schema consts, their z.input aliases and their Parsed aliases) and the ExportApiContracts route map; the IExportService contract with its six types (CreateExportJobInput, CreateExportJobResult, ExportJobDownload, ListExportJobsOptions, ExportJobListResult, ScheduleExportInput) from @objectstack/spec/contracts; and automation/ScheduleState (ScheduleStateSchema, ScheduleState, ScheduleStateParsed) from @objectstack/spec/automation → nothing to re-declare for the job family — no route ever served it, so no caller holds a job id, a progress body or a download link to carry over. The export the platform DOES serve is the synchronous streaming door GET /api/v1/data/:object/export (@objectstack/rest, the SDK method data.export): it answers the file itself as CSV, JSON or XLSX. ExportFormat stays published (ExportImportTemplate still references it). A recurring export is a Job (system/job.zod.ts) whose handler you write, with its cadence on Job.schedule.expression — the one cron slot the platform evaluates. A scheduled flow declares its cadence on its start node (config.schedule), and its run history is ExecutionLog / FlowRunSummary; ScheduleState had no counterpart to point at because no scheduler ever kept one. The import-job family in the same module (ImportJob…, ListImportJobs…, ImportJobApiContracts) is served and is NOT part of this retirement
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling A of 2026-09-12 (retire the family, IExportService and ScheduleExportInput; ScheduleState retired with it unless a live consumer is measured), the landing route the maintainer ruled on 2026-09-24 (route A: objectui retires its own side of the unimplemented async-export path first, then this retirement), and a scope note the maintainer agreed on 2026-09-25 that folds in the declared limit / cursor of the export-job list — one of three sibling list doors found declaring them and never reading them. The family declared an asynchronous export API, create / progress / download / list / schedule / cancel under /api/v1/data/export and a POST on /api/v1/data/:object/export, that NOTHING served: @objectstack/rest mounts no /api/v1/data/export route and only the GET on /api/v1/data/:object/export, IExportService recorded no evidenced provider binding, and the reader census over objectstack outside packages/spec, over objectui at the pinned sha (which carries objectui's own retirement) and over cloud main returned zero code files naming any of the forty-three exported names, each beside a lit control. An AI reading the contract found a complete, well-typed export-job API and wrote calls that answer 404 — and once the retirement of the cron-typed positions nothing read had deleted theirs, ScheduledExport / ScheduleExportRequest kept a REQUIRED schedule block that could hold no schedule, so an author who filled in its timezone believed they had scheduled something. ScheduleState described the runtime state of a scheduled flow that no scheduler wrote or read. 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; none of these shapes is either — they are HTTP bodies, a route map, a service interface and an unpersisted runtime record — so a conversion would be a transform with no seam that ever runs, and with no carrier key there is no shape on which a tombstone could sit. Those earlier cron-position deletions on three of these defs registered nothing and stay unregistered; the defs themselves are now the RETIRED_DEFS_BY_MAJOR[18] entries.
    • Done when: No code imports any of the twelve export-job Schema consts or their type aliases from @objectstack/spec or @objectstack/spec/api, reads ExportApiContracts, implements or imports IExportService or its six types from @objectstack/spec/contracts, or imports ScheduleStateSchema / ScheduleState / ScheduleStateParsed from @objectstack/spec/automation: every such import is TS2305 after upgrade, and there is no working replacement to point at because nothing ever served them. The thirteen defs are absent from json-schema.manifest/api.json and json-schema.manifest/automation.json, from the api-surface / declaration-map / export-origins shards and from the generated reference docs. ExportFormat, ExportImportTemplate, the import validation shapes and the whole import-job family (including ImportJobApiContracts) are unaffected. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: GET /api/v1/data/:object/export answers exactly as before, and every request to a retired path answers exactly as it always did, because nothing ever mounted one. ⚠️ Readers outside objectstack, objectui and cloud are NOT MEASURED — @objectstack/spec is published.
  • field-currency-scale-refused — object.fields.<name>.scale on a field whose typeiscurrency— any declared value,scale: 0included; theField.currencyhelper passes it through unchanged.scaleonnumber, percent, rating, sliderandformula is untouched → no scale on a currency field. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a field setting. The currency's ISO 4217 minor unit decides how the amount displays, and the field's write allowance stays unconstrained — a currency write is accepted with the decimals it carries, as it always was on a currency field that declared no scale. ⛔ Nothing replaces the key: do not re-declare its value under any other key.
    • Why not automatic: The maintainer's ruling of 2026-09-23 (option B) retires scale from the currency field type, and the ruling of 2026-09-24 (option 乙 — a currency's decimal places are the currency's, not a setting) words the remedy. On a currency field the key was three-faced: the metadata-admin field designer offered it as stored metadata, the amount's cell never read it (fraction digits come from the currency's ISO 4217 minor unit), and the record validator's max_scale branch still refused writes carrying more decimals — so an author who set it bought a narrower write contract and no visible change. FieldSchema now refuses the key on currency at parse, and the validator stops reading it for the type in the same release, so a stored declaration narrows nothing either. ⛔ No alias and no grace window, per the ruling. NOT mechanically converted, deliberately: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. Two behaviour changes ride along and are part of what an upgrade means: (1) a currency write with more decimals than a former scale is now ACCEPTED — the write allowance stays unconstrained, the contract every currency field without scale already had; (2) at the console pin measured when this was written, the grid summary footer and the dashboard metric widget read a currency column's scale ?? 0, and the ruling lands this change only after the console derives both faces from the currency, the way the cell does, and the pin has moved past that change. Population measured at the change, on origin/main 1f89ba0d70 by AST sweep: 15 Field.currency declarations in examples/ (app-crm 4, app-showcase 11) and 13 documentation examples carried scale, every one of them scale: 2; all were deleted in the same change.
    • Done when: Every field in the stack parses: ObjectSchema.parse() / objectstack validate report no issue on a scale path of a currency field. A currency field that carried scale no longer declares it, and a diff of the field shows that one line deleted and no key added. Its amount's cell renders the currency's ISO 4217 minor-unit digits, as before, and a write with more decimals than the old scale is accepted where it was refused with max_scale; number / percent / rating / slider fields keep their scale and still refuse over-scale writes.
  • field-inline-and-related-list-columns-closed — field.inlineColumns[] and field.relatedListColumns[] on lookup and master_detail fields — the two column lists that used to accept any object → inlineColumns entries are strict, name-keyed columns — { name, label?, type?, … }, where { name } alone hydrates the rest from the child object field. relatedListColumns entries are child field-name strings.
    • Why not automatic: The D2 conversion field-column-lists-canonicalized rewrites what it can resolve without guessing: an inline column spelled { field: 'x' } becomes { name: 'x' } with every other key kept, and a related-list column object folds to its identity string. Two things remain the author's. First, the conversion leaves alone, on purpose, an inline entry that carries BOTH field and name (rewriting a live key on the strength of a stale one would guess) and a related-list object with no resolvable identity (a conversion must not invent data) — those now fail the parse and only the author knows which column was meant. Second, the fold DROPS a related-list object's decoration keys — a label, a width — because no object spelling rendered reliably on that list; the author decides whether a label they wrote there belongs on the child field itself instead. Both lists used to accept any object, so a mis-keyed column published clean and drew blank cells: a column that was blank before this release was usually one of these, and the author should confirm it now names a real child field.
    • Done when: The object parses: no inlineColumns entry carries field, and every relatedListColumns entry is a string. Every inline column name and every related-list string names a field that exists on the child object, and the inline grid and the related list render a value — not a blank cell — in each column for a record that has one. Any label that the fold dropped from a related-list column is either no longer wanted or now lives on the child field definition, where the list reads it from.
  • field-master-detail-set-null-refused — object field deleteBehavior: 'set_null'authored on amaster_detail field → an explicit deleteBehavior: 'restrict' or 'cascade' (or no declaration, which is the cascade default) — re-declared deliberately, because only the author knows which they meant. There is deliberately NO automatic conversion: 'set_null' here asked for the child rows to be KEPT, and both mechanical rewrites betray that intent in a different direction — stripping the key silently ratifies the cascade the author did not ask for (the same collapse of intent that produced the defect), while 'restrict' is the only rewrite that cannot lose data (the parent delete is refused while children exist — the closest honest reading of "keep my children") but turns a delete that silently succeeded into a loud refusal. If the children genuinely must survive the parent, the field wants to be a lookup, not a master_detail
    • Why not automatic: FieldSchema accepted deleteBehavior: 'set_null' on a master_detail while the engine's cascadeDeleteRelations resolves every value except restrict on that type to cascade — so the declaration asked for the children to be kept and the engine DELETED them, silently, at the moment the parent went away: data loss relative to the declared intent, the ADR-0049 declared-but-unenforced shape on a delete path. Honoring the value is ruled out (maintainer, 2026-08-19): a detail row whose master reference is nulled becomes an unreachable orphan, which is precisely what the orphan-detail work exists to prevent. The schema now refuses the authored combination at parse time (declared = enforced), and the engine logs loudly if a raw registration or a pre-tightening stored row still carries it to the coercion site. A BARE master_detail is untouched: the default still materializes as 'set_null' in parse output (byte-identical to before) and still resolves to cascade.
    • Done when: No master_detail field declares deleteBehavior: 'set_null'. Bare master_detail declarations, authored cascade/restrict, and set_null on lookup parse byte-identically to before. Stored sys_metadata rows carrying the refused combination keep loading and serving (registry validation is a diagnostic, not a gate) but flag metadata_spec_invalid and are refused on their next authoring-path save — re-declare the field deliberately when that happens.
  • field-max-length-malformed-or-misplaced-refused — object field maxLengthdeclarations —maxLength: 0, negative or non-integer values on any type, and the key with any value on field types outside BOUNDED_STRING_FIELD_TYPES (boolean, lookup, autonumber, formula, select, json, secret, …) → a positive-integer maxLength (>= 1) on a bounded-string field type — text, textarea, email, url, phone, password, markdown, html, richtext, code, plus signature/qrcode, which joined once the write seam enforced a declared bound on them (the set is BOUNDED_STRING_FIELD_TYPES; the narrowing itself landed on the ten-member set of its day) — or no declaration at all. Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration: the write-time validator only ever applied max_length inside its bounded-string branch, so the key was inert by construction on every other type. A MALFORMED value on a bounded-string type is the judgment case — the validator's raw > comparison did consume it (maxLength: 0 accepted only the empty string, a negative value refused every write, maxLength: 12.5 behaved as "at most 12"), and the SQL schema-drift planner consumed maxLength: 0 as varchar(0) DDL until it was taught to stop reading a malformed bound as authoritative — so only the author knows the bound they MEANT: re-declare it as a positive integer, or delete it deliberately accepting the unbounding
    • Why not automatic: Maintainer ruling of 2026-08-24, tightening both halves — the value's shape and the types the key applies to (enforcement shipped on the 17.x line — accept-set narrowings ride minors, and this entry tells migrate meta users at the major boundary; registration was deferred to a follow-up because the registry file was serialized behind an in-flight change when the enforcement landed). Shape: a character length is a positive integer, so the key tightened from z.number() to z.number().int().min(1) — maxLength: 0 measurably sent schema-drift planning varchar(0) DDL no server accepts, at severity error/destructive, before that consumer was taught to stop reading a malformed bound as authoritative (the house pattern the precision/scale integer refusal set). Applicability: the key sat on the BASE field schema — authorable on boolean / lookup / autonumber, types where nothing bounded is stored — while the write-time validator (objectql record-validator.ts) only ever enforced it on its ten bounded-string types, the one list of the three that had a measured reader; that list is promoted to the protocol as BOUNDED_STRING_FIELD_TYPES, the schema refuses the key outside it (ADR-0078 declared=enforced), and both authoring forms (field.form.ts, previously 3 types; object.form.ts, previously 9) show the key for exactly that set.
    • Done when: Every field declaring maxLength carries a positive integer and is a bounded-string type. Well-formed declarations (a positive-integer maxLength on a bounded-string type) parse byte-identically to before; fields declaring no maxLength are untouched, and absence stays absence — no default materializes. Deleting a misplaced key changes no runtime behaviour (it sat outside the validator's bounded-string branch and enforced nothing). For a malformed value on a bounded-string type the author decides: re-declare the intended positive-integer bound (enforced by the write-time validator from the next write on, and honoured by schema drift as varchar(n)), or delete the key and accept the type's unbounded/default column shape — either way the accidental old behaviour (empty-only writes under maxLength: 0, unwritable fields under a negative value) is gone by decision, not by silence.
  • field-min-length-malformed-or-misplaced-refused — object field minLengthdeclarations —minLength: 0, negative or non-integer values on any type, and the key with any value on field types outside BOUNDED_STRING_FIELD_TYPES (boolean, lookup, autonumber, formula, select, json, secret, …) → a positive-integer minLength (>= 1) on a bounded-string field type — text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode (the twelve-member BOUNDED_STRING_FIELD_TYPES set; signature/qrcode joined once the write seam enforced a declared bound on them) — or no declaration at all ("no minimum" is expressed by OMITTING the key, never by minLength: 0). Deleting the key is mechanical and behaviour-preserving for a MISPLACED declaration (the write-time validator only ever applied min_length inside its bounded-string branch, so the key was inert by construction elsewhere) and for minLength: 0 / negative values anywhere (a string length is never below zero, so the check could not fire). A FRACTIONAL value on a bounded-string type is the judgment case: the validator's raw < comparison did consume it (minLength: 2.5 behaved as "at least 3"), so only the author knows the integer they MEANT — re-declare it deliberately if the constraint was wanted
    • Why not automatic: Maintainer ruling of 2026-08-25 (option B, the lower bound at 1): minLength carried the exact defect pair the 2026-08-24 ruling closed for maxLength (field-max-length-malformed-or-misplaced-refused), and converges on the same template. Shape: the key was z.number(), so minLength: -5 and minLength: 2.5 parsed cleanly while describing no character length; it is now z.number().int().min(1). The lower bound is 1 by ruling: minLength: 0 is refused loudly — a vacuous always-true declaration is exactly the noise an AI metadata author mass-produces, and the refusal surfaces it at authoring time. Applicability: the key sat on the BASE field schema — authorable on boolean / lookup / autonumber, types where nothing bounded is stored — while the write-time validator (objectql record-validator.ts) only ever enforced it on the bounded-string set; the schema now refuses it outside BOUNDED_STRING_FIELD_TYPES (ADR-0078 declared=enforced), and both authoring forms (field.form.ts, previously 3 types; object.form.ts, previously 9) show the key for exactly that set.
    • Done when: Every field declaring minLength carries a positive integer and is a bounded-string type. Well-formed declarations (a positive-integer minLength on a bounded-string type) parse byte-identically to before; fields declaring no minLength are untouched, and absence stays absence — no default materializes. Deleting a misplaced key or a 0/negative value changes no runtime behaviour (misplaced keys sat outside the validator's bounded-string branch; a 0/negative bound could never fire). Deleting a fractional value on a bounded-string type relaxes the write seam by up to one character — the author decides whether to delete or re-declare the integer they meant; a wanted minimum is re-declared as a positive integer and enforced by the write-time validator from the next write on.
  • field-multiple-non-capable-type-refused — object.fields.<name>.multiple — an authored multiple: trueon a field whosetype is outside MULTI_CAPABLE_TYPES (select/radio/lookup/user/file/image) union MULTI_OPTION_TYPES (multiselect/checkboxes/tags) — e.g. master_detail, tree, text, boolean, datetime, avatar`` → a multi-capable type that actually holds several values: multiselect / checkboxes / tags for several option codes, a lookup with multiple: true for several related records (the replacement for a multi-valued master_detail / tree), file / image with multiple: true for several attachments — or, where the field really does hold one value, dropping the multiple key. MULTI_CAPABLE_TYPES and isMultiValueField are unchanged, so every field that was ALREADY multi-valued by that predicate keeps its declaration, its storage and its read path verbatim.
    • Why not automatic: Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing an authored radio with multiple: true, generalised): two definitions of "multi-valued" disagreed. FieldSchema accepted multiple: true on ANY type; driver-sql's isJsonField read it raw (|| !!field.multiple) and built a JSON ARRAY column; isMultiValueField — the spec predicate consumers shape queries from — answered "not multi-value" for the same field. A related list therefore composed = against a JSON array column and the driver answered the user a 400 (the console's related list pinned the divergence on the consumer side when it began shaping that filter from the spec predicate, and its follow-up recorded the driver half as owed and not filed). There is NO lossless conversion: the column was physically built as a JSON array, so the stored value is an array while the replacement type may want one scalar, several ids, or several option codes — which of those the author meant is a business judgment the chain cannot make. Hence a structured TODO rather than an auto-rewrite (ADR-0087 D3 "never silence", ADR-0032 "no silent failure"). Population measured at ruling time: 0 in-tree and 0 in HotCRM (shallow clone c716a2c) — every multiple: true there is on lookup / select; re-measured on origin/main 689d606f by AST sweep, still 0. WIDER THAN THE JSON-COLUMN DECISION ALONE: every site in driver-sql that asked field.multiple "is this value multi-valued" now asks isMultiValueField — the DDL writer, the read-side deserializer, the varchar-width mirror, the cross-field comparison class, the four scalar read-coercion registries on both of their fills, the two MySQL temporal-widening candidate sets, and the schema differ. So a stored field in the retired shape also LEAVES the JSON read path and ENTERS the scalar one: its column is no longer deserialized as JSON, the declared-type text-operator gate applies to it, and a $contains against it answers the declared no-match instead of a membership test.
    • Done when: Every field in the stack parses: ObjectSchema.parse() / objectstack validate report no issue on the multiple path. For each field the refusal names — the message states the object-qualified field name and its type — the author has either dropped multiple or moved the field to a multi-capable type AND migrated the stored column, because the two storages differ: the old column holds a JSON array, the new one holds a scalar (dropping multiple) or a differently-shaped array (changing type). Prove the data half by reading one migrated row back through the API and asserting the value shape the new declaration promises; = filters against the field answer rows instead of a 400, and a $contains against it answers by member rather than the declared no-match. Fields already multi-valued by isMultiValueField need no change and must read back byte-identically.
  • field-predicate-reference-traversal-refused — the field-level predicates objects[].fields[].requiredWhen and objects[].fields[].readonlyWhen, and a select option's objects[].fields[].options[].visibleWhen, whose CEL reads THROUGH a reference field (a lookup, master_detail, user or tree field): record.account.tier where account is such a field; likewise previous.account.tier, and parent.account.tier on a master-detail line item whose master declares account. Refused wherever objects are validated as authored: objectstack validate, build and lint over defineStack({ objects }) sources and exported stacks → the check as a validations[] rule of type: 'script' — the one predicate the server reads one hop through a reference (the related record is loaded before it runs) — whose condition states the FAILURE. For requiredWhen: P on field F: P and F empty, e.g. record.account.tier == 'enterprise' && (record.po_number == null || record.po_number == ''). For readonlyWhen: P on F: P and F changed, on updates only (events: ['update']), e.g. record.account.tier == 'gold' && record.discount != previous.discount. For an option gated by P: that option picked while P does not hold — the option is then offered to everyone and refused on save. Or read a column the object itself declares (denormalise the related value onto it). A read through previous or parent has no hydrated seam at all, a validation rule included: read a column the bound record declares instead
    • Why not automatic: Triage routed this on 2026-09-25 to remedy A: refuse the traversal at authoring, with a prescription. The field level is never hydrated: rule-validator.ts evaluates requiredWhen / readonlyWhen / an option's visibleWhen against the record alone, so a reference there holds the related record's bare id and every read through it faults, on every row. Measured on the engine before this change: a traversing requiredWhen refused every insert and every update that reached it, a traversing readonlyWhen refused every update that wrote its field (an insert is exempt), and an option gated through a reference was admitted whatever the related record said (option visibility is fail-open) — while objectstack validate passed a stack carrying all three, exit 0. ADR-0137 D2 made the runtime fail closed; the defect was that authoring did not say so first (NORTH-STAR priority rule 4). The same traversal inside a validations[] script rule is served, one hop deep, and stays accepted. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: moving a field predicate into a validation rule turns a condition into a FAILURE condition, moves an option from hidden to offered-then-refused, and the right events scope depends on what the author meant — none of it mechanical. Hydrating the field level instead is a capability of its own and is not done here. ADR-0087, ADR-0137.
    • Done when: Run objectstack validate over the stack. Each such predicate is refused as expression-invalid, located at object 'O' · field 'F' requiredWhen (or readonlyWhen, or option 'V' visibleWhen), and the message names the reference path read through (through record.account) and the repair — that is the TODO's locator. Rewrite each per the replacement note until validate is clean. Then prove the behaviour on a running stack: a write meeting the condition is refused by the new rule (rule_violation carrying its message), and one that does not is accepted — where before, every write reaching the field predicate was refused with could not be evaluated … write rejected, or the option was admitted unchecked. ⚠️ An object already stored in sys_metadata is not re-validated by this change: its writes keep being refused at run time exactly as before, and that refusal names the reference for a record read — its own locator.
  • field-reference-to-spelling-retired — field.reference_to — the legacy runtime spelling of a lookup or master_detail target, on object fields and object-extension fields → reference — the one spelling the field schema has ever accepted, and the one the wire serves.
    • Why not automatic: The D2 conversion field-reference-to-alias renames reference_to to reference in author sources and on every stored-row rehydration, so the wire only ever carries reference; for a row with only the legacy spelling the rename is lossless. Two things are left. First, a row carrying BOTH spellings with DIFFERENT targets is left untouched, on purpose: the loader will not pick a target for the author, so that field keeps failing its parse until someone decides which object it points at. Second, code is out of reach: a plugin, script, custom renderer or external client that read reference_to off served field metadata worked only because a stored row happened to carry the legacy spelling, and it now reads nothing — the frontend fallback that tolerated the spelling is scheduled to go, after which a missed reader degrades a lookup to a picker with no target. The camelCase referenceTo is a different surface (resolved action params) and is not part of this family.
    • Done when: No object or object-extension field carries reference_to in source or at rest — every stored field serves reference. Each field that had carried both spellings names one target, chosen by the author. No code outside the metadata reads reference_to from a field definition. Every lookup and master_detail field opens a picker scoped to the object its reference names, and saving a selection stores that object's record id.
  • field-scale-precision-integer-refused — object field scale/precision declarations (Field.number and friends) — non-integer or negative values (scale: 2.5, precision: -1) → a non-negative integer digit count, or no declaration at all. The mechanical conversion (field-malformed-scale-precision-removed) deletes a malformed value — behaviour-preserving, because the write-time scale enforcement deliberately skipped malformed declarations, so they enforced nothing — but only the author knows the count they MEANT (scale: 2.5 was probably 2 or 3): re-declare it deliberately if the constraint was wanted
    • Why not automatic: Both keys are digit COUNTS ("Total digits" / "Decimal places"), and z.number() admitted values with no defined meaning as a count. That looseness became load-bearing when scale was made enforced at write time (an over-scale write refused, never rounded): the runtime branch deliberately guards on Number.isInteger(def.scale) && def.scale >= 0 — inventing floor/round semantics in a consumer would be PD #12 guessing — so a typo'd declaration (scale: 2.5) silently got no enforcement at all: exactly the declared-but-inert shape that hides AI-authored metadata errors. The schema now refuses non-integer and negative values for both keys at parse time (z.number().int().min(0), ADR-0078 declared=enforced). CurrencyConfigSchema.precision (under currencyConfig) was a different surface with its own bounds and alias table — retired in this same protocol major by currency-config-precision-removed, not enforced here.
    • Done when: Every field declaring scale or precision carries a non-negative integer. Well-formed declarations (0, 2, any non-negative integer) parse byte-identically to before; fields declaring neither key are untouched. Stored sys_metadata rows carrying a malformed value keep loading (the rehydration seam replays the conversion, which drops the meaningless key).
  • filter-between-blank-endpoint-refused — either endpoint of a $between range, authored BLANK — the empty string, or an absent (undefined) bound — in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — the enforced FieldOperatorsSchema / RangeOperatorSchema copy itself, reached by a caller that validates a filter against it directly, and the NormalizedFilter AST. (b) NOT JUDGED AT SAVE — every stored metadata carrier, and that is BOTH authoring dialects, not only the loose one. A view, page or component filter RULE (ViewFilterRuleSchema) admits it: the rule value accepts a string and the operator-shape check judges ARITY alone, so a two-element range with a blank element is a well-formed rule. A dashboard widget filter, a dashboard options-source filter, a dataset filter, a dataset measure filter, a report runtimeFilter, a rollup summaryOperations filter and a relatedListFilter are typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for BLANKNESS or for ARITY. Measured: a dashboard widget whose filter reads close_date $between 2026-01-01 and an empty string parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the engine comparand-shape door. ARITY is not what changed: a blank bound is a well-formed TWO-element range one of whose elements means nothing → two endpoints that are present and non-empty — the bound the author meant, written out. If only ONE side is genuinely bounded, that is not a range at all: drop $between and write the side you have as a scalar comparison, {"$gte": min} for a lower bound and {"$lte": max} for an upper one, which every backend already answers. ⛔ There is no replacement that can be DERIVED from what was written: the bound the author did not type is not recoverable from the one they did, and picking either reading (drop the operator, or treat the blank side as unbounded) would be the platform inventing a filter. null bounds are a different entry: they were already refused by the 2026-08-31 ruling, whose message prescribes the null predicate because a null author was reaching for absence, not for a bound
    • Why not automatic: Maintainer ruling A of 2026-09-17: a blank $between endpoint is refused at the authoring door, and the refusal names the blank side. FieldOperatorsSchema.safeParse({ $between: [1, ''] }) answered success: true — measured on the card against the installed spec 17.4.0 and re-measured on origin/main before the change. This is a NEW RULE narrowing a published face, ⛔ not a pull-back to a declared one: the endpoint contract shared by both bounds says verbatim that "Each endpoint is a number, a Date, or a string", and the empty string is a string, so the acceptance was conformant. What made it wrong is the other half of the same contract — "Closed interval [min, max]" — which no backend can honour against a blank: driver-sql binds it into whereBetween, the JS matchers compare it as a value, and the range stops bounding on that side while still reading as a complete range. The reference matcher had already been taught to survive the null-bound form of exactly this (a bounded range answered EVERY valued row, because both of the arm's comparisons are false against a missing bound); the door that admitted it was never addressed. The only producer ever measured is a UI builder padding a HALF-TYPED pair with '' so that a length-based completeness check passes it — nobody WANTS a blank bound, which is why it is refused rather than given a published meaning (option B was declined: a semantics nobody asked for, to be honoured per driver). The refusal names the blank SIDE (MIN / MAX plus the index) because with a padded pair both bounds are present and the author is the one person who cannot see which is empty. Scope is the empty string and undefined and nothing wider: whitespace-only endpoints are deliberately NOT judged, since narrowing a published face further than the ruling is the seat call this card's whole history refuses to make. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on origin/main, a stored view carrying { close_date: { $between: ['2026-01-01', ''] } } comes back as the SAME object reference. So the load path today neither drops a refused operator nor refuses the row, and no conversion in the registry drops a filter OPERATOR (the three filter-adjacent entries are key strips and a key rename). That is also the precedent the two nearest narrowings of this same surface set — filter-preset-ordering-comparand-refused and analytics-date-range-array-two-bounds-required — both of which decline a D2 conversion on the ground that rewriting would be the platform guessing which bound was meant. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and WIDENS the result set silently, the failure mode $nin carries in the same file. The read path does not re-validate stored rows, so no stored view becomes unreadable — and, because every stored carrier is typed loosely or judged by arity alone (see surface), re-saving one is not refused either. What changes is the enforced operator schema itself, which answers at the endpoint's own path with the blank side named, and the engine comparand-shape door, which refuses an executed filter carrying one. The objectui half — the builder stops padding a half-typed pair, so the console never meets this refusal mid-typing — is a change to the console's own filter builder and lands on its own schedule, either side of this one. ADR-0049 / ADR-0078 / ADR-0087.
    • Done when: Grep every authored $between array — view, page and component filter rules, dashboard widget and options-source filters, dataset and dataset-measure filters, report runtimeFilters, rollup and related-list filters, saved AST filters, SDK and MCP callers — and read BOTH of its elements. A range with two present, non-empty endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, clock times and non-temporal text included, and ['0', '9'] and [0, 100] are untouched (the rule is blankness, not falsiness). An empty-string or absent bound now answers one prescriptive issue at that endpoint's own path ($between.0 / $between.1) naming MIN or MAX, and a range blank on BOTH sides reports both positions. ⚠️ THE DETECTOR IS NOT THE SAME ON EVERY CARRIER, and re-saving the document is mechanical on NONE of them. FieldOperatorsSchema.safeParse is mechanical, and it is the whole of what the save door offers: it answers for a caller that validates a filter against that schema, or against the NormalizedFilter AST, directly. ⛔ Re-saving surfaces NOTHING for a stored document — not a dashboard, dataset, report, rollup or related list, whose filter slots are FilterConditionSchema, and not a view, page or component filter RULE either, whose value check judges arity and not blankness, so a range written ['2026-01-01', ''] saves exactly as green as it always did. Measured, not assumed. For those carriers the detectors are the GREP above and EXECUTING the surface, where the engine comparand-shape door refuses with INVALID_FILTER / 400 naming the index and the side. ⛔ Do not read a clean re-save of a dashboard — or of a view — as a completed sweep. Nothing is normalised on the way through — no bound is trimmed, defaulted or copied from its neighbour — so an accepted range arrives byte-identical to what was written. ⚠️ Do not assume a converted range was previously showing the window it named: a blank bound stopped bounding on that side at every backend, so the surface was reading a wider set than its filter claimed. Decide the window from what the surface was SUPPOSED to show, and if only one side was ever meant, write it as $gte / $lte rather than inventing a second bound. null bounds are unaffected by this entry and keep their own refusal and prescription.
  • filter-between-field-reference-endpoint-refused — either endpoint of a $between range, authored as a { $field } column reference, in any filter this platform stores or executes. The carriers split in two, because they are DETECTED differently and only one half answers on save. (a) REFUSED AT SAVE — a view, page or component filter RULE (ViewFilterRuleSchema, whose value is shaped by the operator) and the NormalizedFilter AST the query faces validate against, plus the enforced FieldOperatorsSchema copy itself. (b) NOT JUDGED AT SAVE — a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter and a relatedListFilter: every one of those slots is typed FilterConditionSchema, a loose record intersected with the $and / $or / $not shape and carrying one refinement. ⚠️ That refinement DOES judge an operator map, so the slot is not unjudged: a bare date-range PRESET name in an ordering position — a $gt / $gte / $lt / $lte value, or a $between endpoint — is refused at that position's own path, and it is refused through an otherwise GREEN document; the sibling entry filter-preset-ordering-comparand-refused is that rule, on this same carrier set. What these carriers never judge is a $between endpoint for a COLUMN REFERENCE or for ARITY. Measured: a dashboard widget whose filter reads close_date $between { $field: "contract.start" } and 2026-12-31 parses GREEN, as do a one-element, a three-element and an empty $between, while the same widget carrying a preset endpoint is refused at filter.close_date.$between.0. So for THIS shape every one of those documents parses GREEN and the endpoint is refused only when the filter is EXECUTED, at the runtime lowering door this change closes. ARITY is not what changed: a reference endpoint is a well-formed TWO-element range one of whose elements no backend resolves → a literal bound — the value the range was meant to stop at, written out. If the range was genuinely meant to be COLUMN-TO-COLUMN, that is not a $between at all: write the two bounds separately as scalar comparisons, {"$gte": {"$field": "a"}} for the lower bound and {"$lte": {"$field": "b"}} for the upper one, which is the position the column-to-column comparison compiles on every face. ⛔ There is no replacement that can be DERIVED from what was written: the literal a reference stood for is not recoverable, and dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. A reference remains legal, unchanged, as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte
    • Why not automatic: Maintainer ruling of 2026-08-11 on column-reference range endpoints, ADR-0049 enforce-or-remove: REMOVE. Both $between endpoint unions carried FieldReferenceSchema and no backend ever resolved one in a list position — matches-filter.ts leaves the list unresolved and orders against the raw reference OBJECT, so the range silently matches nothing, and both SQL faces refuse the position with INVALID_FILTER / 400. The published endpoint contract has stated the rule verbatim since that day: "A { $field } reference is NOT an endpoint shape" (RANGE_ENDPOINT_DESCRIPTION, packages/spec/src/data/filter.zod.ts). ⚠️ That ruling shipped at the AUTHORING SCHEMA door alone, and no ledger entry was written for it — measured before this change: no semantic entry, no retired key, no spec-changes row and no upgrade-guide line named the shape. That was not an omission, and this entry SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset carried the disposition not-required (no-migration-prescription), reviewed and accepted with that ruling and shipped in the published CHANGELOG. What changed is the fact that disposition rested on. It was claimed for a removal whose reach was believed to be the authoring schema alone; the runtime half now ships with a migration prescription of its own (below), and a body carrying a prescription is exactly what that category refuses. So the transition is registered here, covering BOTH doors, and the earlier not-required reading is retired by this record. The runtime lowering door disagreed with the declaration for the whole of that window: parseFilterAST({ f: { $between: [{ $field: "a" }, "M"] } }) returned the filter unchanged, same object reference, measured on origin/main immediately before the change and re-measured after. One published sentence, two truth values, decided by which door a caller came through — and the door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver. This entry therefore registers the transition for BOTH doors, not only the second, which is why it is filed as an entry of its own rather than as an already-registered rider. ⚠️ No D2 conversion and no stored-metadata rewrite, and the load path was MEASURED rather than assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — never throws and never validates, and replays only the positively-recognised lossless transforms in the conversion registry; measured on this branch, a stored view carrying { close_date: { $between: [{ $field: "contract.start" }, "2026-12-31" ] } } comes back as the SAME object reference. Rewriting is not available in principle here, not merely declined: the literal the author meant is not recoverable from a reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another. That is the same ground the two nearest narrowings of this surface set stand on — filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused. The read path does not re-validate stored rows, so no stored view becomes unreadable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep every authored $between array — view and dashboard widget filters, dataset filters, report runtimeFilters, page and component filters, rollup filters, saved AST filters, SDK and MCP callers — and read BOTH of its elements for a { $field } key. A range with two literal endpoints parses byte-identically to before, numbers, Dates, ISO days, UTC instants, clock times and non-temporal text included; nothing is trimmed, defaulted or copied from its neighbour, so an accepted range arrives byte-identical to what was written. ⚠️ THE DETECTOR IS NOT THE SAME ON EVERY CARRIER, and re-saving the document is mechanical on only one half of them. Re-saving DOES answer for a view, page or component filter RULE and for the NormalizedFilter AST: a reference endpoint reports an issue at the rule's own value path (for a list view, list.filter.N.value) or at the endpoint's own AST path ($between.0 / $between.1) — note the schema door names the INDEX, while the runtime door additionally names the side, MIN or MAX. ⛔ Re-saving surfaces NOTHING for a dashboard widget filter, a dataset filter, a report runtimeFilter, a rollup filter or a relatedListFilter: those slots are FilterConditionSchema, whose one refinement judges bare date-range preset comparands and nothing about a reference endpoint, so such a document parses green — measured, not assumed. For those carriers the detectors are the GREP above and EXECUTING the surface, where the runtime lowering door now refuses with INVALID_FILTER / 400 naming the index and the side. ⛔ Do not read a clean re-save of a dashboard as a completed sweep. A range whose BOTH endpoints are references reports both positions at the schema door; the runtime door throws on the first. ⚠️ Do not assume such a range was showing the window it named: at every backend it either matched NOTHING (the in-memory matchers) or was refused (both SQL faces), so a surface carrying one was never answering the query its filter claimed — decide the window from what the surface was SUPPOSED to show. If the intent was column-to-column, the replacement is the two-bound spelling and it needs testing as a NEW filter, because nothing was ever evaluating the old one. Endpoints that are null, blank or of the wrong type keep their own refusals and their own entries. $in / $nin MEMBERS carrying a reference are ruled out by the same 2026-08-11 decision and refused at the authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry's transition and are worth sweeping in the same pass.
  • filter-comparand-types-and-widget-nested-slots-refused-at-save — data.FilterCondition — a comparand the comparand-type face refuses, now refused when the document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, text or flag comparand such as { a: 1 }, including a { $field } whose name is not a string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand or an $in / $nin / $between list member. On every schema that carries a FilterCondition, at the reach the save door already had (the field entries of a condition and of every $and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, INSIDE a nested-relation condition as well, for these shapes and for every shape the earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset carriers → a value of one of the six accepted comparand types — a string, number, bigint within plus or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / $ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a string or within range. Inside a nested relation on a widget filter or a report runtimeFilter, write the same spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} string resolved at request time (such as {current_user_id} or {today}) and a bigint within 2^53 are untouched, and the save door keeps a bigint as written
    • Why not automatic: The save door narrows to exactly what the query faces already refuse (the second stage of closing the family of comparand shapes the save door accepted and the query faces refused). The comparand-type face (normalizeFilterComparandTypes, the accepted set the maintainer ruled on 2026-08-12: string, number, bigint, boolean, null and Date) refuses these values on every query: parseFilterAST, the engine seam, the analytics where door and the read-scope compiler all run it. Measured on origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, { stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type face and the analytics where door refused each with INVALID_FILTER / 400. And a dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested equality slot, which the analytics where door refuses when they are charted, because only the two dataset carriers had the nested-relation walk. The save door now asks the type face itself, read-only, after the shape face, so it refuses exactly what the face refuses and passes what it passes; one slot raises one refusal, in the query doors' order (shape, then type, then the flag rule), in the face's own words less its location clause. At the top level of a filter and in its $and / $or / $not members, a second issue on a slot a face already refused (the schema door's own $icontains and date-preset arms) is no longer raised; inside a nested relation on an analytics carrier those two arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same analytics-carrier filter as the dataset carriers, so their nested-relation slots are judged by the same walk: every stored filter the analytics where door charts now refuses on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these values has a single honest meaning as a comparand, which is why each was refused. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. A JSON document can carry only the plain-object cells; the others arrive only from TypeScript authoring. Such a filter has failed every query since the type face's ruling, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each refused slot by path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type face's sentence and the accepted set. A producer census before the change — a literal scan with a lit control per shape over examples, the non-test packages of this repository, the console repository at its pin and the cloud repository, plus a runtime walk of every filter in the example stacks — found no authored filter carrying one of these values and no widget filter or report runtimeFilter with a nested-relation condition holding a list or an operator map.
  • filter-equality-array-comparand-refused — data.FilterCondition — an ARRAY as an EQUALITY comparand, at the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): the implicit form { field: [...] } — which the FilterArray sugar ["field", "equals", [...]] lowers to, and likewise "=", "==" and "eq" — and the explicit form { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included → the operator the list was standing in for. "One of these values" is $in: { field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all (the has-no-value predicate), is untouched; and $ne is NOT judged by this entry
    • Why not automatic: Maintainer ruling of 2026-09-23 (option 乙 — rather than declaring an array equality the SQL-family backends would have to invent, or documenting a divergence that stays silent on one backend): an array in the implicit-equality slot is refused at the shared face, for every driver at once — no alias, no grace window. The comparand-shape face declared that moving a rule to it 「closes that door for every driver at once」, and before this change it judged only the list-operator slot; the equality slot passed both shared doors and each backend answered it alone. Measured on the lowered node { tags: ["a"] } at this release, beside a scalar and an $in control. driver-sql on SQLite REFUSED it with INVALID_FILTER / 400 at the top level, and nested under $and / $or / $not answered 500 DATABASE_ERROR instead (driver-turso and driver-sqlite-wasm are built on driver-sql and were not run separately). driver-memory REFUSED it with INVALID_FILTER / 400 at every depth. The formula matcher returned no row, including a row storing exactly ["a"]. driver-mongodb ANSWERED it: its translateFilter emits the array unchanged, and MongoDB equality on an array operand selects a stored array equal to ["a"] or holding ["a"] as an element — mingo 7.2.4, the named proxy, over ["a"], "a", ["a","b"], ["b","a"], [["a"],"x"], [["a"]], "b" and [] selected ["a"], [["a"],"x"] and [["a"]]. The service-analytics filter normalizer read the FilterArray form as MEMBERSHIP: ["stage", "=", ["won", "lost"]] charted as stage IN (won, lost), and its OBJECT form read the same list four ways: { stage: [...] } as IN, $eq with a list as its first member alone, $eq with an empty list as no predicate, and an empty implicit list as the FALSE constant. A live mongod, MySQL, PostgreSQL and a live Turso server were NOT measured. So one stored filter was a 400 on most backends and a silent, differently-shaped row set on one. The shared face now refuses it with INVALID_FILTER / 400 before any driver runs, naming the field, the path and both remedies. Which doors refuse it at this release, and with what: the shared face, inside parseFilterAST and at the engine lowering seam, with INVALID_FILTER / 400; the analytics where door in BOTH spellings, the FilterArray form through parseFilterAST and the OBJECT form because that door hands each equality-slot list to the shared face before it builds a node, with the same INVALID_FILTER / 400 and the same sentence (that door alone, among the runtime doors, also refuses a list inside a nested-relation condition, which it flattens to a dotted member); and, on SAVE, the schema door (FilterConditionSchema and the $eq operator slot), with the same sentence as a parse issue at the filter's own path, which is the sibling entry filter-equality-array-comparand-refused-at-save, plus the two carriers that analytics door charts (a dataset filter and a measure filter) inside a nested relation too, which is dataset-filter-nested-relation-equality-array-refused-at-save. The ruling records the hosted product as running on the SQL family, where the top-level shape was already a 400, so the population that can observe a change is self-hosted driver-mongodb, plus any filter nested under a combinator on the SQL family (a 500 becomes a 400). $ne carrying an array measured the same split and is deliberately left to its own ruling. Metadata AT REST is not rewritten and this entry adds no D2 conversion: an array on equality has no single honest value, and choosing between $in and $contains is the author's call, not the platform's. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep stored filters, dataset and widget filters, flow node filters and code that builds a where for a field whose value is an array — { field: [...] }, { field: { $eq: [...] } }, or a FilterArray triple on =, ==, eq or equals carrying an array — then decide per filter what it meant: one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value. Each is refused at query time with INVALID_FILTER / 400 naming the field and the path, so a test suite that exercises the query finds every one; a stored carrier is also refused on save, which is the sibling entry filter-equality-array-comparand-refused-at-save. A dashboard or dataset filter written as the FilterArray sugar with an array on equality, or as the implicit object form, charted as membership through the analytics normalizer; $in is the spelling that charts the same rows. On driver-mongodb re-check what the query is supposed to return rather than assuming the old rows were right: the old answer was MongoDB array equality, which neither $in nor $contains reproduces.
  • filter-equality-array-comparand-refused-at-save — data.FilterCondition and the $eq slot of data.FieldOperators — an ARRAY as an EQUALITY comparand, now refused when the document is PARSED: the implicit form { field: [...] } and the explicit form { field: { $eq: [...] } }, the empty array included, on every schema that carries a FilterCondition — a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where — plus FieldOperatorsSchema.$eq, its documentation copy EqualityOperatorSchema.$eq, and the NormalizedFilter AST that validates against it → the operator the list was standing in for, exactly as in filter-equality-array-comparand-refused. "One of these values" is $in: { field: { $in: ["a", "b"] } } (authoring spelling "in"). "The stored multi-value field holds this value" is $contains with ONE member: { field: { $contains: "a" } } (authoring spelling "contains"), and an $or of those for any-of. A filter that meant a single value writes that value: { field: "a" }. The list operators ($in / $nin / $between) keep their arrays, empty lists included; every scalar equality comparand, null above all, a Date and a { $field } reference are untouched; and $ne is NOT judged by this entry
    • Why not automatic: Ruled on 2026-09-24 (option A), applying the standing refusal of an array in the equality slot to the schema door: FilterConditionSchema (implicit equality) and FieldOperatorsSchema.$eq refuse an array comparand at parse, with the SAME remedy text the shared compile face emits — one constant, two doors; a stored filter carrying the shape is refused loudly on its next save, and never silently dropped, because a dropped filter shows MORE rows than intended. Measured on origin/main a0920b42dc before the change: a dataset whose filter was { stage: ["won", "lost"] }, and one whose measure filter was { stage: { $eq: ["won", "lost"] } }, both parsed GREEN, as did FilterConditionSchema and FieldOperatorsSchema on the bare shapes, while the shared comparand-shape face refused both with INVALID_FILTER / 400. So such a document published clean and then failed every query that used it, for a different person, later. The schema door now prints the face's own sentence, from one builder both doors import; the only difference is that the face appends the location (at where.stage) and the schema door does not, because its issue carries the location as its path (filter.stage, measures.0.filter.stage.$eq). The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, but NOT a field spec with no $ key (a nested-relation or deep-equality condition), which the face never descends either. ⚠️ Three positions therefore still refuse only at execution. (1) A list inside a nested-relation condition, { account: { region: ["a"] } }: the analytics where door flattens that to the dotted member account.region and refuses it when the filter is charted; a dataset filter and a measure filter refuse it on save as well, which is the sibling entry dataset-filter-nested-relation-equality-array-refused-at-save. (2) The where option of the data-engine calls (find, count, update, delete, aggregate, vector find): its type is a union whose first arm is an open record, so it parses and the face refuses it when the call runs. (3) $ne carrying a list, which no ruling has decided. Two request doors parse these carriers and now answer the shape before the analytics compiler does: the REST dataset selection (its runtimeFilter) and the analytics query body (its where) refuse with VALIDATION_FAILED / 400 and this sentence at the field, one step ahead of the compiler's INVALID_FILTER / 400. Metadata AT REST is not rewritten and this entry adds no D2 conversion, for the reason the runtime entry gives: an array on equality has no single honest value. The read path does not re-validate stored rows, so a stored document keeps loading; what changes is that re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime entry, and on the SQL family before it at the top level, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each array in an equality slot by path with the field, the received list and both remedies, so the sweep is mechanical for the carriers listed in the surface. Decide per filter what it meant — one of these values ($in), the stored list holds a value ($contains, an $or of them for several), or one value — and re-check what the surface is supposed to show rather than assuming the old rows were right: on most backends the filter had been failing every query. ⛔ A clean re-save is NOT a complete sweep for the three positions the reason names: grep nested-relation conditions and data-engine where options for a field whose value is a list, and exercise them, where the runtime doors refuse with INVALID_FILTER / 400 naming the field and the path. A dataset filter or a measure filter is the exception: its nested-relation lists are refused on save too (dataset-filter-nested-relation-equality-array-refused-at-save).
  • filter-icontains-comparand-refused-at-parse — the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, dashboard and analytics filters) and the infix spelling icontains on ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the EMPTY STRING or is not a string at all → a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate that constrains nothing, so the repair is to DROP the condition rather than to write something in it. A comparand that was a number, boolean or null is written as the string it was meant to match: value 42 becomes value "42" only if a substring match on the two characters is really what was meant, and if it is not, the operator was the wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand and this rule says nothing about it
    • Why not automatic: The protocol half of the maintainer's 2026-09-20 ruling (option C-prime) on the console's filter converter, whose first rule reads, verbatim and untranslated: 「the differences are the protocol's to close」. The platform already DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a REJECTION row for an empty comparand and one for a non-string comparand, each with code INVALID_FILTER and each requiring the refusal to name the operator. All five driver packages run both rows in their own suites, and the drivers re-run for this change (driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter) each refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse them, it answers false for every row. Nothing applied them at PARSE on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it — the declared-not-enforced shape ADR-0049 exists to close. The narrowing is DERIVED from the table, not transcribed beside it: both doors call the published predicate isRefusedTextComparand and the published reason text textComparandRefusalReason, the pair published in this package beside FILTER_TEXT_CASES for exactly this reason, so a row added to the table reaches both doors without an edit at either. $contains, $startsWith, $endsWith, $like and $ilike keep the answer they give today, because widening by analogy is the table's decision and not a door's. The two vocabularies differ on one point and it is a fact about them rather than an extra rule: a view rule's value key is OPTIONAL, so an absent comparand is left unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand slot is the refused non-string shape — the same reading the comparand-type door already takes of that cell. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. An empty comparand has no lossless replacement (dropping a condition changes which rows a view returns, which is the author's decision) and a non-string one has no honest coercion (the platform refuses to answer a query nobody wrote). The read path does not re-validate stored rows, so a stored filter keeps loading; what changes is that RE-SAVING it is refused, with the reason text three shipped consumer faces already show at query time. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep your authored filters for the case-insensitive contains operator in either spelling and read each comparand: an empty one means the condition was a placeholder and the repair is to delete it, and a non-string one means either a missing pair of quotes or the wrong operator. At this release neither comparand returns rows: each of the five driver packages answers both with INVALID_FILTER at query time, and the formula matcher answers false for every row. How earlier releases answered them was NOT measured, so re-check what the view is supposed to show rather than assuming the old result set was correct. Both refusals now arrive at the authoring path.
  • filter-is-empty-lowers-to-empty-operator — data.FilterCondition — the lowering of the view operators is_empty, isempty, is_not_empty and isnotempty (on a ViewFilterRule, a sharing rule and any filter array), and a $empty object written as a record field value → Nothing to rewrite for a rule on a declared field: is_empty now lowers to { field: { $empty: true } } and is_not_empty to { field: { $empty: false } }, answered by the field's declared type — a text-like field is empty when it is null or the empty string, a multi-value field when it is null or the empty list, every other type only when it is null. Where no face holds the column's declared type the rule is refused: on the built-in id, write is_null / is_not_null; on a federated object whose driver does not implement external-object registration (driver-memory, driver-mongodb), bind it on a driver that implements federation (the boot error names the object); on an AnalyticsService built without sourceFieldMeta, pass sourceFieldMeta or write is_null / is_not_null; on a multi-value column over a SQL dialect driver-sql does not model, write is_null / is_not_null. A record write that carries a $empty object as a field value writes the value itself instead; a filter belongs in where
    • Why not automatic: One ruling set what 「is empty」 means once, per field type; a second spelled it as the $empty operator, which each compile face expands from the field's declaration. It was staged out of FILTER_OPERATORS until every face answered it, then added in the same change that flipped the lowering, after measuring that no face drops it. Two consequences reach stored metadata. A stored 「is empty」 on a text or multi-value field finds more rows: the ones holding the empty string or the empty list, which the $null lowering missed. And the rule is refused where the face that answers it holds no declaration for the column — the four compositions the replacement names — where the $null lowering compiled IS NULL. The same change made the write door refuse a $empty object as a field value, because that door refuses every filter operator the protocol enforces as a value: before, a text-like field stored it. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: the stored spelling is unchanged, and its new meaning is the ruled one. ADR-0087 / ADR-0112.
    • Done when: Grep your stored views, sharing rules and filter arrays for is_empty / is_not_empty. On a text or multi-value field, re-check what the view or rule is supposed to select. On the built-in id, rewrite it to is_null / is_not_null. If a federated object on driver-memory or driver-mongodb, or an AnalyticsService host without sourceFieldMeta, carries such a rule, the query now fails instead of answering — bind the object on a federation-capable driver, or pass sourceFieldMeta. No insert or update payload carries a $empty object as a field value.
  • filter-ne-array-comparand-refused — data.FilterCondition and the $ne slot of data.FieldOperators — an ARRAY as the comparand of $ne. At the runtime filter doors (the shared comparand-shape face that parseFilterAST and the engine lowering seam both run): { field: { $ne: [...] } }, which the FilterArray sugar ["field", "ne", [...]] lowers to, and likewise "!=", "<>", "neq", "not_equals" and "notequals", at any depth under $and / $or / $not, the empty array included. At parse: FieldOperatorsSchema.$ne, its documentation copy EqualityOperatorSchema.$ne, and the NormalizedFilter AST that validates against it → the declared list-negation operator. "None of these values" is $nin: { field: { $nin: ["a", "b"] } } (authoring spellings "nin", "not_in", "notin"). A filter that meant a single value writes that value: { field: { $ne: "a" } }. $ne: null (the has-a-value predicate), every scalar, a Date and a { $field } reference are untouched, and the list operators ($in / $nin / $between) keep their arrays, empty lists included
    • Why not automatic: Ruled on 2026-09-24 by the director seat, on the standing contract text (option A): the shared comparand-shape face refuses an array under $ne for every driver, and FieldOperatorsSchema.$ne refuses it at parse, with one remedy text naming the declared list-negation operator by its spec spelling — no alias, no window. The governing text is $ne's own published describe: the comparand is a literal, or a { $field } reference to another column of the same table. An array is neither, so the refusal pulls the doors back to what $ne already declared. Measured on the card before any stage landed, on the lowered { tags: { $ne: ["a"] } }: driver-sql and driver-memory REFUSED it with 400; driver-mongodb ANSWERED it as MongoDB reads $ne against an array operand, not equal to that array and not holding it as an element, which is every scalar row (mingo, the named proxy; a live mongod was NOT measured); and the formula evaluator matched EVERY row, which on the row-level write check admitted every write a != policy against a list was written to refuse. Those two answering faces were closed first, each at its own face, under rls-predicate-array-comparand-refused and cel-predicate-list-comparand-refused. Measured on origin/main 9e7824a4, after both and before this change: the shared face passed the shape at every depth (so did its FilterArray lowering, and the engine's delegating wrapper), and FieldOperatorsSchema, EqualityOperatorSchema and the NormalizedFilter AST all parsed it GREEN. Now the face refuses it with INVALID_FILTER / 400 before any driver runs, and the operator slot refuses it on parse, with one sentence from one builder: the face names the field and appends the location (at where.tags.$ne); the slot cannot see either, and its issue carries the location as its path. On the SQL family and driver-memory the verdict does not move (400 before, 400 after); the text and the moment move, to the face, before any driver. ⚠️ Not moved by this entry: FilterConditionSchema, the schema every stored filter carrier parses through (dataset, dashboard widget, report, rollup and the rest), does not parse a field's operator map through FieldOperatorsSchema and its own walk does not judge $ne, so such a carrier still SAVES a $ne list and the face refuses it at query time; the ruling names the face and the operator slot, not that walk. Metadata AT REST is not rewritten and this entry adds no D2 conversion: a list under $ne has no single honest value, and whether it meant none of these values or one value is the author's call. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep stored filters, dataset and widget filters, flow node filters and code that builds a where for $ne whose comparand is an array — { field: { $ne: [...] } }, or a FilterArray triple on ne, !=, <>, neq, not_equals or notequals carrying an array — then decide per filter what it meant: none of these values ($nin), or one value ($ne with that value). Each is refused at query time with INVALID_FILTER / 400 naming the field, the path and $nin, so a test suite that exercises the query finds every one; code that parses a filter with FieldOperatorsSchema or the NormalizedFilter AST is refused on parse at the $ne path. ⛔ A clean re-save of a stored carrier is NOT a sweep: the carrier schema does not refuse the shape, so exercise each stored filter or grep it. On driver-mongodb re-check what the query is supposed to return rather than assuming the old rows were right: the old answer was MongoDB array inequality, which $nin does not reproduce.
  • filter-preset-ordering-comparand-refused — a dashboard date-range preset name (last_7_days / last_30_days / last_90_days, today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year) authored as a bare ORDERING comparand in a filter — a $gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / before / after / between view filter rule value, or an ordering [field, op, value] filter triple. WHICH DOOR refuses it at publish is decided by the carrier's declared type and by its key. The carriers measured fall in groups, and the groups are a list of what was measured, not a closed partition: the grep in the acceptance criteria is the catch-all. (1) A slot typed FilterConditionSchema — a dashboard widget filter, a dashboard global-filter options-source filter (optionsFrom.filter), a dataset filter, a dataset measure filter, a report runtimeFilter (on the report or on a joined-report block), a rollup summaryOperations.filter and a relatedListFilter — is refused at PARSE, at the comparand's own path, and the @objectstack/lint filter-preset-comparand rule reports it as well. (2) A filter under a key the lint walks, whose declared type carries no preset check, parses GREEN, and the lint rule is the only door that refuses it: a ViewFilterRuleSchema rule array (a view's filter, a page element's dataSource.filter, a page component's filter prop), and a Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a flow CRUD node's config.filter). The lint is likewise what refuses a preset in an ordering filter triple wherever its walk meets one → the date-macro window the preset already means — { $gte: "{30_days_ago}" } for last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on (the rejection names the exact window per preset; DATE_RANGE_PRESET_MACRO_WINDOWS in @objectstack/spec/data is the table) — or an ISO date such as 2026-01-15. The preset names themselves stay fully legal where a layer resolves them to a window: the dashboard date-filter positions (dateRange.defaultRange, a date global filter defaultValue) and an analytics query's timeDimensions[].dateRange. A filter comparand is not one of those positions
    • Why not automatic: The authoring half (option C) of the maintainer's 2026-08-15 ruling on uninterpretable temporal comparands, ruled alongside the engine door (option B) that refuses them at query time. The preset vocabulary is declared in the dashboard schema and lowered to {date-macro} bounds by the shipped console before any query is sent — so the names were declared in one layer and unrecognised in the next, with no error at the boundary. Authored as a bare comparand (a saved report, an integration, an MCP client, an AI-authored query), the name reached the driver as written and compared false against every row: HTTP 200, count 0, indistinguishable from "there is no data" (measured on the defect report: $gte "last_30_days" returned 0 of 51 seeded rows where the macro spelling returned the 38 in-window). The engine now refuses the bare name on a declared temporal field at query time (INVALID_FILTER / 400); this entry records the AUTHORING-time half, and that half is two doors with different reach, not one: the FilterConditionSchema parse refuses the shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand rule refuses it on every filter its walk reaches, which makes it the only door for a walked filter whose declared type carries no preset check. Both answer at publish, where the author — an AI author in particular — can still act on the message; the surface's groups say which measured carrier sits under which door. Ordering positions only at the schema door, deliberately: it judges no equality or membership, because a select/picklist column legitimately stores values that collide with preset names and a schema has no field type in hand. The lint rule, which reads the stack's object metadata, additionally refuses a preset in an equality or membership position, in a filter its walk reaches, on a field it can resolve to a declared date or datetime (where the filter binds to no object, or the field resolves to nothing, that arm cannot fire), and on a temporal field the engine door already refuses those with the field type in hand. ⚠️ Metadata AT REST is deliberately not rewritten and there is no D2 conversion: this shape was never written by any first-party producer (every preset in this repo and the example apps sits in a dashboard date-filter position — measured) and never executed usefully (it returned a silent zero before the engine door and a 400 after). Coercing it at load would be the platform guessing which bound the author meant. The read path does not re-validate stored rows, so no stored dashboard becomes unreadable; what changes is that RE-SAVING one is refused with the window named. ADR-0049 / ADR-0078 / ADR-0112.
    • Done when: Grep your authored filters for the thirteen preset names in ordering positions — a $gt/$gte/$lt/$lte value, a $between endpoint, a greater_than/less_than/before/after/between view rule value, an ordering filter triple, a gt/gte/lt/lte lookup filter value — and rewrite each to the {date-macro} window the rejection names (or an ISO date). That grep is the catch-all; the surface's groups are the carriers measured. The sweep is mechanical for groups (1) and (2): os validate / os lint report each one by path, and a group (1) slot is also refused by a safeParse of the schema that declares it, at the comparand's own path. Leave presets in dashboard date-filter positions (dateRange.defaultRange, date global filter defaultValue) untouched — they remain the declared vocabulary there. A filter that carried one of these shapes was never returning the window it named (silent zero before the engine door, 400 after), so re-check what the surface was supposed to show rather than assuming the old result set was correct.
  • filter-query-face-comparands-refused-at-save — data.FilterCondition — every comparand slot the query faces refuse, now refused when the document is PARSED: a $null or $exists flag that is not a boolean (a string such as "false", null, a number); a null $gt / $gte / $lt / $lte comparand; an $in or $nin comparand that is not a list, or a list holding null; a $between comparand that is not a two-element list, or whose endpoint is null, blank or a { $field } reference; and an array under $ne. On every schema that carries a FilterCondition: a dataset filter and a dataset measure filter, a dashboard widget filter and an options-source filter, a report and joined-report-block runtimeFilter, a field relatedListFilter and a rollup summaryOperations filter, a solution-blueprint summary filter, an analytics query where, a dataset selection runtimeFilter, a query where and having, the data-engine aggregate call's having, an aggregation filter and a query-filter where; and, on a dataset filter and a dataset measure filter only, the same slots INSIDE a nested-relation condition → the spelling the refusal prescribes, which is the one the query faces already prescribe. A flag is the boolean itself: $null true is "has no value", $null false is "has a value", and $exists is the inverse. Absence is the null predicate, never null in an ordering or list position: $eq null is "has no value", $ne null is "has a value", and "one of these values OR has no value" is an $or of an $in and a $null true. A single value for $in is a one-member list, or plain equality. A range is two bounds in a two-element list; a range bounded on one side is a $gte or a $lte; a column-to-column range is a $gte and a $lte whose comparands are { $field } references. "None of these values" is $nin, never $ne with a list. The null predicate itself, a { $field } reference as a whole comparand, an empty $in or $nin list and a whitespace endpoint are untouched
    • Why not automatic: The save door narrows to exactly what the query faces already refuse (the family of comparand shapes the save door accepted and the query faces refused; the $ne member is route A, the same reach and the same one sentence as the equality slot of filter-equality-array-comparand-refused-at-save). The shared comparand-shape face refuses on every query a null ordering comparand (ruled 2026-09-01), a non-list $in / $nin and a malformed $between range, a null list member or endpoint (ruled 2026-08-31), a blank endpoint (ruled 2026-09-20), a { $field } endpoint (ruled 2026-08-11) and an array under $ne (ruled 2026-09-24); every query face refuses a non-boolean $null / $exists flag, because the backends read one in opposite directions. Measured on origin/main af32cf9a before the change: a dataset filter, a dataset measure filter, a dashboard widget filter and a report runtimeFilter each parsed GREEN for one instance of every shape the surface names, while the face refused each one with INVALID_FILTER / 400 and the analytics where door refused every one of them, the flags included. So such a document published clean and then failed every chart built on it. The save door now asks the face itself about each slot, so it refuses exactly what the face refuses and passes what the face passes; the words are the face's, or the sentence the enforced operator slot already prints for the same comparand, never the face's location clause, which the issue's path carries instead. The reach is the face's and no wider: the field entries of a condition and of every $and / $or / $not member, and NOT a field spec with no $ key (a nested-relation condition), which neither the face nor the drivers' flag checks descend. The analytics where door DOES descend one (it flattens the relation to dotted members and judges each), so the two dataset carriers, whose own nested-relation walk already refused an equality list there (dataset-filter-nested-relation-equality-array-refused-at-save), now ask the same judge about every slot inside a relation. ⚠️ So one position still refuses only at execution: a refused shape INSIDE a nested-relation condition on a dashboard widget filter or a report runtimeFilter, which reach the analytics where door too but carry the shared schema's reach only. Metadata AT REST is not rewritten and this entry adds no D2 conversion: none of these shapes has a single honest meaning (that is why each was refused), and a conversion would have to pick one. The read path does not re-validate stored rows, so a stored document keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), defineStack or os validate is refused at the filter's path. Such a filter has failed every query since the runtime refusal of its shape, so the refusal is a repair and not a loss. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every stored document that carries a filter: os validate or defineStack, and a save through the metadata protocol, report each refused slot by path with the operator, the field and the prescription, so the sweep is mechanical for the carriers the surface lists. Decide per filter what it meant and write that spelling; on most backends the filter had been failing every query, so re-check what the surface is supposed to show rather than assuming the old rows were right. One producer was measured before the change: a filter builder that writes "is empty" / "is not empty" as an $in / $nin list holding null and the empty string (the Studio filter-condition widget, at the console pin of that date); what it wrote is refused on its next save. ⛔ A clean save is NOT a complete sweep for the one position the reason names: search dashboard widget filters and report runtimeFilters for a nested-relation condition whose inner field carries one of these shapes, and chart it, where the analytics where door refuses with INVALID_FILTER / 400 naming the field and the path.
  • filter-text-operator-declared-type-refused — a STORED filter body the engine executes, where a text operator names a field whose declared type can never store a string. Measured carriers: sys_saved_report.query_json.filter(executed verbatim asengine.find(object, { where: q.filter }), and reached again by every sys_report_schedulerow through itsreport_id), FieldSchema.summaryOperations[].filter(ANDed with the parent-FK match and handed toengine.aggregate), ListView.filter and tab filters (ViewFilterRuleSchema, whose contains/not_contains/icontains/starts_with/ends_withspellings lower to the same operators throughAST_OPERATOR_MAP), and the FilterConditionSchemacarriers on dashboards (widgetfilter, GlobalFilter), datasets and reports (runtimeFilter), plus FieldSchema.relatedListFilter. NOT this surface: an RLS / sharing / tenant predicate, which the platform composes onto the AST AFTER this door and which the door therefore never judges. → compare the field with an operator its declared type can answer — $eq / $ne / $in, or a range ($gte / $lt) for a temporal or numeric field — or aim the text operator at a text-valued field instead. A dotted path into a structured-JSON field (address.city) stays legal and is deliberately unjudged. NO rewrite is mechanical: the author's intent is not recoverable from the stored condition — { amount: { $contains: '5' } } may have meant $eq: 5, a range, or a filter on a different column altogether — so the loader must not choose one.
    • Why not automatic: Maintainer ruling of 2026-09-05 (option C-deny: refuse now, over the type sets the contract already declares, minting no new vocabulary), landed at the engine seam. A text operator ($contains / $notContains / $startsWith / $endsWith / $icontains / $like / $ilike) over a field whose DECLARED type can never store a string — NUMERIC_VALUE_TYPES ∪ BOOLEAN_VALUE_TYPES ∪ CALENDAR_DATE_TYPES ∪ INSTANT_TYPES ∪ CLOCK_TIME_TYPES ∪ STRUCTURED_JSON_TYPES — is refused at the engine's field-aware door with INVALID_FILTER 400 instead of reaching a driver. It is a RUNTIME narrowing over an AUTHORED surface, which is why it is registered here rather than disposed of as needing no prescription: NO schema changed, so a stored filter carrying the refused shape still parses and still loads — FilterConditionSchema constrains no field type, and ViewFilterRuleSchema takes field: z.string() with contains in its operator enum — and the first sign of it is a 400 on the read that executes it. Before the door those reads answered [] (or every row for $notContains, or a SQLite coercion accident) with no diagnostic, which is the silent cell the ruling closed. objectstack migrate meta cannot repair the stored bodies for the reason replacement records, so this is a structured TODO rather than a graduated conversion.
    • Done when: Every stored filter body listed under surface executes without an INVALID_FILTER 400 naming a declared type: run each saved report, list view, dashboard widget, dataset and roll-up once after the upgrade and read the refusals — each message names the filter key, the field's declared type and the operator, which is the whole repair list. A filter re-authored onto a typed operator returns the rows its author meant; one left as written keeps answering 400, and NOTHING silently rewrites it. This door judges a field by its declared type alone, so it never refuses a text operator over a text-valued field — select / radio codes, multiselect / checkboxes / tags, lookup and user ids, autonumber and the file classes. Over every such field that is not stored as a JSON column (below), filters must keep answering exactly as before; that is the control which proves a repair pass did not over-reach. A DIRECT driver call bypasses this door entirely and keeps answering the FILTER_TEXT_CASES stored-value row (a stored value that is not a string never satisfies a positive text operator and satisfies $notContains), so a driver-level test is not evidence about this migration in either direction. A field stored as a JSON column is NOT that control, because a separate door judges it by its storage rather than its declared type: multiselect / checkboxes / tags, any field declared multiple: true (a multi-valued lookup or user among them) and, on a SQL deployment still inside the ADR-0104 dual-encoding window (its media columns not yet moved), a single-value file-class field. That door refuses every text operator there except the membership pair $contains / $notContains — $startsWith, $endsWith, $icontains, $like and $ilike, beside the scalar comparisons it already refused — with an INVALID_FILTER 400 that names no declared type, so a stored filter left on one of those operators there answers that 400 after the upgrade and is outside this entry's repair list. On a multi-valued field its repair is membership, which no rewrite chooses either: $contains for one member, an $or of $contains for any-of. A single-value file-class field is not a membership question: it answers text operators again once its deployment finishes the media-column move (the column step of objectstack migrate files-to-references --apply).
  • flow-approval-node-config-contract-refused — an approval flow node whose config the approval node contract (ApprovalNodeConfigSchema) refuses — a key it does not declare (escalation.bogusKey, a top-level key such as steps or onApprove, an alias such as escalation.timeout), a value it refuses (escalation.timeoutHours below 1, an unknown behavior or escalation.action, an empty approvers list, a fallbackApprovers list under any policy but fallback), or a key it requires left out (approvers; escalation.timeoutHours inside an escalation block). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata → the shape the approval contract declares, written on the node's config: approvers with at least one approver, and inside an escalation block a timeoutHours of at least 1 (wall-clock hours; timeoutHours: 1 is the shortest SLA the contract accepts). An undeclared key is renamed to the key the refusal's did-you-mean names (timeout → timeoutHours, mode → behavior, quorum → minApprovals) or deleted; a process-level key (steps, entryCriteria, onApprove, onReject, rejectionBehavior) moves onto the flow graph as the refusal's guidance says. To turn an SLA off, delete the whole escalation block — an escalation: { enabled: false } with no timeoutHours is refused like any block missing it
    • Why not automatic: An approval node's executor (plugin-approvals) parses node.config against ApprovalNodeConfigSchema before it does anything else and fails the node on ANY issue. Registration already refused an undeclared key, against the descriptor's published configSchema, but a refused value (timeoutHours: 0.5) registered and then failed every run that reached the node — the config is metadata, and no rerun could succeed. The build doors asked about neither: FlowSchema.parse judged only the builtin node types' executor contracts, and only for a key left out, so objectstack validate and objectstack compile exited 0 on an escalation.bogusKey or a timeoutHours: 0.5 and compile copied it into the artifact. The contract is the spec's own, so the build can judge it with no plugin loaded: the approval node joins a declared contract map beside the builtin executor contracts, read by the one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first) and objectstack validate share (flowNodeConfigRefusals), and is judged WHOLE — every issue the contract raises is refused, because the executor refuses on every one. An undeclared key or a refused value is node-config-refused-by-contract, anchored at the key, in the contract's own sentence (its did-you-mean included); a key left out keeps node-config-key-missing or node-config-key-required-by-rule. The builtin arm is unchanged and stays presence-only. A plugin node type whose contract the spec does not declare stays outside the build doors, as before. ⚠️ No D2 conversion: the platform cannot know the approvers, the key or the value the author meant, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0019.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.config.<key> (nodes.N.config.escalation.bogusKey, nodes.N.config.escalation.timeoutHours, nodes.N.config.approvers), objectstack validate prints the same path, and validateStackExpressions phrases it as node 'gate' (approval) config.escalation.bogusKey. For each hit write what the contract accepts, per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it — that warn line is the locator for a row that exists only in sys_metadata. An approval node the contract accepts parses and registers byte-identically to before.
  • flow-binding-variable-dollar-name-refused — flows[].nodes[].config.outputVariable of a get_record, create_record, map, script or subflow node, and flows[].nodes[].config.errorVariable of a try_catch node — a variable name that starts with a dollar sign such as $caught, other than the default $error on errorVariable. Reachable wherever a flow is authored or stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already in sys_metadata → the same name without the dollar sign, read as a hole over that name: errorVariable: 'caught' read as {{ caught.message }}, outputVariable: 'lead' read as {{ lead.name }}. A try_catch may instead drop errorVariable and read the engine's default, {{ $error.message }}. Rename every read of the old name with it — a text-slot hole, a CEL expression, a single-brace token in a value position
    • Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, a flat-graph loop binds $loopItems and $loopIndex, and a resume signal may not write any dollar name. A flow text slot refuses a hole whose root is a dollar name the engine does not bind, so a flow that bound $caught as its errorVariable could not read {{ $caught.message }}: the refusal told the author to drop the dollar sign, while the binding key itself took any string. One contract had two answers to whether an author may own a dollar name. The binding keys now give the text slots' answer: each key states the rule as a JSON Schema pattern, so the published schema refuses what the parse refuses, and the node contract, registerFlow, objectstack validate and the run itself refuse such a name at the key, naming the same name without the dollar sign. A binding over an engine name (outputVariable: '$record') would also have overwritten the engine's value for the rest of the run. No D2 conversion exists: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks, so the rename is the author's. Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a stack source throws StackSchemaInvalidError for the whole stack. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key — nodes.N.config.outputVariable or nodes.N.config.errorVariable, or the region path nodes.N.config.try.nodes.M.config.outputVariable — and the name to write. Rename the binding and every read of it, then (1) objectstack validate is clean, (2) each flow registers at boot with no failed to register flow warn for it, and (3) the flow paths that read the variable — a notification, a screen, a later node — carry its value, with no blank fragment.
  • flow-builtin-node-config-undeclared-keys-refused — a get_record, create_record, update_record, delete_record, notify, http, screen, map, loop or parallel flow node whose config carries a key its executor contract does not declare — a typo (titl), a key the walk at registration already named (fieldValues on a write node, bulk on update_record, visibleIf on a screen field), a key copied from another node type (outputVariable on an http node, flowName on a loop), or a key nothing reads (bogusKey) — at the config itself, or on a screen field or one of its options, a body-less legacy loop included. Never a key inside a free-form map (a filter, fields, headers, defaults, input, payload or templateData key is author data), never a key on a region object (a loop body, a parallel branch) or on its nodes and edges (the region check at registration owns those), and never a try_catch key, which try-catch-and-retry-policy-undeclared-keys-refused covers once the retry policy closed. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata → the key the contract declares, or no key: rename a typo to the declared key it meant (the refusal carries the contract's did-you-mean for a near miss), follow the contract's own prescription for a known slip (fieldValues → fields, bulk / all / multiple → multi: true, options: { multi } → a top-level multi, a screen field's visibleIf → visibleWhen, a loop's itemVariable → iteratorVariable), and delete a key nothing reads (an http node's outputVariable among them: the http executor binds no output variable)
    • Why not automatic: Each of these executors (service-automation builtin/crud-nodes.ts, notify-node.ts, http-nodes.ts, screen-nodes.ts, map-node.ts, loop-node.ts, parallel-node.ts) parses the node's config against a strict contract before it acts. Until now the build doors' executor-contract arm held key membership back on these types, on the premise that registration judges it: registerFlow's undeclared-key walk (validateNodeConfigKeys) refuses such a key against the node type descriptor's configSchema. So a notify node carrying bogusKey passed FlowSchema.parse, objectstack validate and objectstack compile (which copied it into the artifact), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. The one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first), objectstack validate and the metadata save door share (flowNodeConfigRefusals) now refuses such a key on these types as node-config-refused-by-contract, anchored at the key, one refusal per key, in the contract's own words and closed with the rename-or-remove remedy, and the descriptor walk stands aside for every type that judge covers (builtinNodeConfigKeysJudged), so each type has one judge. Measured before the move: on each of these types the descriptor's declared key sets, at every position the walk descends to, equal the keys the contract accepts there, so registration refuses exactly what it refused before. ⚠️ try_catch is the one builtin not moved here: its contract's retry was the shared RetryPolicySchema, which stripped an unknown key, while its descriptor closes retry to five keys. It moves in try-catch-and-retry-policy-undeclared-keys-refused, once that schema closed. ⚠️ A body-less legacy loop is not parsed at run time, and it is judged here on key membership alone, which is what registration refused there already. ⚠️ A spelling an ADR-0087 D2 conversion still rewrites at load (object and filters on a CRUD node, to / subject / body / url on a notify, flow on a map) is converted before the judge at every door that converts first; met by a direct FlowSchema.parse or defineFlow() it is refused like any other undeclared key. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused, as registration already refused it: from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load; a save from Studio answers 422 naming the key. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.config.<key> (nodes.N.config.bogusKey, nodes.N.config.fields.0.visibleIf, or the region path nodes.N.config.body.nodes.M.config…), objectstack validate prints the same path, and validateStackExpressions phrases it as node 'n' (notify) config.bogusKey. For each hit rename or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it — that warn line is the locator for a row that exists only in sys_metadata. A node of these types whose keys its contract declares parses and registers byte-identically to before.
  • flow-builtin-node-config-values-refused — a builtin flow node (get_record, create_record, update_record, delete_record, notify, http, screen, script, subflow, map, loop, parallel, try_catch) whose config carries a value its executor contract refuses — a value of the wrong type (create_record outputVariable 42, a screen field min written as the string 1, get_record limit as a string, update_record multi as a string), a value outside the declared set or range (notify severity loud, screen mode view, loop maxIterations 0, try_catch retry.maxRetries above 10), an empty script function or subflow flowName, or a rule finding on present keys (a notify template beside an inline title). Never a value carrying a token in braces, an undeclared or retired key, a region slot, or an http signingSecret. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata → the value the contract declares, written at the key the refusal names: a string where it wants a string (outputVariable: 'taskId'), a number where it wants a number (min: 1, limit: 10, maxIterations: 5, timeoutMs: 5000), a boolean where it wants a boolean (multi: true, durable: true), one of the declared values (severity: 'warning', mode: 'edit'), or a value inside the declared range. Outside http, a number or boolean slot takes a LITERAL only: those executors parse the config as authored, so a {token} template there (limit: '{page.size}', maxIterations: '{cap}') passes the build doors and still fails every run. Only http interpolates its config before it parses, so only an http slot may also take a sole-token template that resolves to the declared type (timeoutMs: '{timeout}', durable: '{durable}'). For a rule finding, follow the rule's own sentence (keep template or the inline title / message, not both)
    • Why not automatic: Every builtin executor (service-automation builtin/) parses its node's config against the contract getBuiltinNodeConfigContracts() names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses — create_record outputVariable: 42, a screen field min: '1' (the shape the Studio designer used to store for a field's Min / Max) — passed FlowSchema.parse, objectstack validate and objectstack compile, registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first) and objectstack validate share (flowNodeConfigRefusals) now refuses such a value as node-config-refused-by-contract, anchored at the key, in the contract's own words — the code the approval contract already uses. It judges only what the build can know the run will parse, and holds one more class back by ruling: a value carrying a {token} is never refused at the build doors for its pre-interpolation type — which is no promise it runs, since every builtin but http parses its config as authored and so still refuses a token in a number or boolean slot at its first run; http parses after interpolating its whole config, so only token-free values are judged there and never signingSecret, which the credential channel may supply; a loop with no body is not parsed by its executor and is judged for nothing; the region slots of loop, parallel and try_catch are judged as graphs of their own and by validateControlFlow. An undeclared or retired key, a predicate ledger slot (a screen field visibleWhen) and a value ledger slot (a CRUD fields value) keep the judges they had. ⚠️ No D2 conversion: the platform cannot know the value the author meant. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.config.<key> (nodes.N.config.outputVariable, nodes.N.config.fields.0.min, or the region path nodes.N.config.body.nodes.M.config…), objectstack validate prints the same path, and validateStackExpressions phrases it as node 'mk' (create_record) config.outputVariable. For each hit write the value the contract declares, per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it — that warn line is the locator for a row that exists only in sys_metadata. A node whose values its contract accepts parses and registers byte-identically to before. A {token} template parses and registers as before too, and runs only where the run parses it after interpolation (http) or where the slot takes a string; in a number or boolean slot of any other builtin it fails at its first run exactly as it did, so write a literal there.
  • flow-decision-branch-expression-absent-refused — a decision node branch — an element of config.conditions[] — written without its expression key, or with expression: null, at any depth including an ADR-0031 region body. That includes a branch whose predicate sits under another key (condition is the edge spelling). Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer with a branch row whose expression cell is empty, and a flow row already sitting in sys_metadata → the predicate the branch was meant to test, as non-blank bare CEL text under expression ({ label: 'high', expression: 'record.amount > 10000' }); a predicate written under condition moves to expression. To keep the branch and its label but never take it, write expression: 'false' — that is a CHANGE of behaviour, not a preserved one: a run that reached the branch used to fail there (condition evaluation error), and now routes on to the next branch or the declared fallback. ⚠️ Not by dropping a decision's only branch: with no conditions the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back
    • Why not automatic: DecisionConditionSchema declares a branch { label, expression } with expression a required z.string(), but nothing parses a decision node's open config against it, and the expression-ledger resolver skipped an absent value as "not authored" — so a branch with no predicate passed FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate, and the decision executor then handed evaluateCondition an envelope with no source, which it refuses: the build accepted what the run refused. The ledger now marks the slot required (reconciled against that schema's own required list), the resolver emits the absent value there, and all three doors refuse it through predicateSlotRefusal, leading with PREDICATE_SLOT_STRING_REFUSAL — the walk, function and sentence that already refuse the blank string. ⚠️ No D2 conversion: the platform cannot know the rule the author left out, and 'false' would change what the flow does rather than keep it. ⚠️ Where such a branch already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032.
    • Done when: Grep every flow node in defineStack({ flows }) sources, exported stacks and every flow row in sys_metadata — including nodes inside a loop / parallel / try_catch region body — for a decision node whose config.conditions[i] has no expression key, or expression: null. Each refusal names the node and the branch: FlowSchema.parse anchors a custom issue at nodes.N.config.conditions.I.expression (or the region path nodes.N.config.body.nodes.M.config…), and objectstack validate prints the same path; validateStackExpressions phrases it as node 'check' (decision) decision branch expression at config.conditions[0].expression. For each hit write the predicate the branch was meant to test, or expression: 'false' where the branch should keep its label and never be taken. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it (the three boot paths spell it [Automation] failed to register flow, [Automation] flow re-sync: failed to register flow and [Automation] cold-boot flow bind: failed to register flow) — that warn line is the locator for a row that exists only in sys_metadata. A branch carrying a non-blank predicate parses and registers byte-identically to before, a decision with no conditions still routes by its out-edges, and an absent screen field visibleWhen is still legal.
  • flow-decision-edge-branching-first-match — flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches on its out-edges and carries two or more conditioned ones → nothing, where the out-edge conditions partition (exactly one can hold for any record): an omitted mode now means exclusive, the first true edge in declaration order wins, and the run is what it always was. mode: 'inclusive' where the flow RELIES on more than one branch running for one record — the value the D2 conversion flow-decision-mode-inclusive-explicit writes onto every such decision so nothing changes silently. Where the conditions overlap by accident (a != guard beside a later == branch), neither: narrow them into a partition, or mark the fallback isDefault: true, and delete the written key.
    • Why not automatic: A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs and the engine's own comment all called an edge-branched decision an exclusive gateway while the traversal took EVERY out-edge whose condition held, one after another, and reported nothing — a CRM application's lead-conversion flow rendered a refusal screen AND ran the conversion in one execution. The traversal now matches the declaration (BPMN exclusive gateway, Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the BPMN inclusive gateway an author must write down. The KEY converts mechanically and does: flow-decision-mode-inclusive-explicit writes mode: 'inclusive' wherever two or more conditioned out-edges leave a decision that declares no conditions list, so the migrated source runs exactly as before. What does NOT convert is the INTENT: the count cannot tell a partition (where the key is redundant) from a reliance on multi-branch runs (where it is load-bearing) from an accidental overlap (where the old behaviour was the bug), so the mechanical edit list the chain replay prints is where that judgment is made, node by node. And the conversion replays ONLY there: it is a default flip, so the authoring funnel never rewrites a source written against the new contract, and the automation engine's flow rehydration seam and the artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by maintainer ruling: the promise that a flow keeps its behaviour is kept by authored sources and built artifacts only. A decision stored in sys_metadata with no conditions list, no mode and two or more conditioned out-edges takes the new meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no stored-row migration, no cutoff, no read-path completion, because nothing about a stored row says it was saved before the flip. The one-line fix, for a stored node that meant every branch, is mode: 'inclusive'; os migrate meta --stored lists every such node, report only, so an operator can review the candidates before and after the upgrade.
    • Done when: Review every flow-decision-mode-inclusive-explicit line the chain replay lists for each authored stack: (1) where the two (or more) out-edge conditions partition — a predicate and its negation, > beside <=, or a guard beside isDefault: true — delete the written mode; the run is unchanged either way and the exclusive default is the honest declaration; (2) where the flow relies on more than one branch running for one record, keep mode: 'inclusive'; (3) where the conditions overlap by accident, narrow them into a partition and delete the key, then re-run the flow on a record that satisfied both and confirm exactly one successor ran — the passed-over branch now leaves a skipped step in the run log. os validate reports flow-decision-inclusive-overlap on every decision that keeps the key with two or more conditioned out-edges, so the review list is the lint output. Then each deployment: os migrate meta --stored lists, under decisionModeReview, every stored decision with two or more conditioned out-edges and no mode — each one already evaluates first-match, and the pass writes none of them — so where one of those nodes meant every branch, declare mode: 'inclusive' on it in the designer; a node that declares mode either way leaves the list. A decision registering with mode beside a non-empty conditions list, or with a mode outside 'exclusive' | 'inclusive', is refused at registration and by os validate with the schema's own sentence; nothing else about conditions-list decisions changes. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
  • flow-edge-condition-evaluated-slot-source-required — a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition on a flow NODE, which is a decision node predicate and on a start node the trigger gate — authored either as an expression envelope carrying only ast ({ dialect: 'cel', ast: … } with no source), or with a source that is blank after trimming, through the envelope key ({ dialect: 'cel', source: ' ' }) or the bare-string shorthand for it (condition: ' '). The node slot joined this entry with the two later changes that rebound AutomationEngine.registerFlow and objectstack validate to the edge door's own rule rather than deriving a second one; it is the same decision reaching the second slot, which is why it is named here instead of in an entry of its own. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, a POST /api/v1/automation body, and a flow row already sitting in sys_metadata → a non-blank source — { dialect: 'cel', source: 'record.amount > 10' }, or the bare string 'record.amount > 10' — if the edge was meant to branch; or REMOVE the condition key entirely if it was meant to be unconditional. ⚠️ Those two are not interchangeable, and the choice is the judgment this entry delegates: a refused condition evaluated to a silent false, so the edge NEVER fired, while an absent condition is an unconditional edge that ALWAYS fires. Deleting the key to clear the refusal inverts the edge rather than preserving it. An ast BESIDE a string source is untouched and stays admitted everywhere
    • Why not automatic: The evaluated-slot rule, carried to the edge condition — the line that first refused an ast-only envelope no engine can evaluate, and refused a non-string node predicate at registration instead of letting the evaluator answer it a silent false: FlowEdgeSchema.condition now composes EvaluatedExpressionInputSchema instead of ExpressionInputSchema, so an evaluated slot is held to what the engine can actually run. The engine reads source alone (cel-engine.ts evaluate: "AST-only evaluation not yet supported; persist source"), so both refused spellings landed in its empty-source arm and answered a SILENT false on every release that carried them — they parsed, registered, passed objectstack validate, and then produced a branch that quietly never fired (measured on 2026-09-05 by driving an ast-only envelope through AutomationEngine.evaluateCondition directly). The refusal is one rule with one sentence, EVALUATED_EXPRESSION_SOURCE_REQUIRED. ⚠️ No D2 conversion is possible, and this is exactly why the change needs a D3 entry rather than none. An ast-only envelope carries no source to derive one from — lowering an AST to surface syntax is the compiler direction the platform does not run — and dropping a blank condition would flip the edge from never-fires to ALWAYS-fires, which is the platform guessing which of two different flows the author meant. ⚠️ And the consequence for a flow ALREADY STORED is wider than the edge, which is the part no author-time prescription reaches. applyConversionsToStoredItem is deliberately not applied to flow (spec/src/conversions/stored.ts, and the same skip in metadata/src/loaders/database-loader.ts rowToData) because flow-node conversions need the automation engine's live executor registry; flows canonicalize at registerFlow instead, which parses through canonicalizeStoredFlow → FlowSchema.parse. Each of the three boot paths in service-automation/src/plugin.ts wraps that call in try/catch, logs one warn naming the flow, and CONTINUES — so a stored sys_metadata flow with such an edge is no longer registered at all: its trigger is never armed and the WHOLE flow stops running, not just the branch, announced only by that warn line. A repo-wide census at ae19f5edb (examples/, packages/, content/, skills/) found zero edge conditions of either spelling against a lit control, so there is nothing in THIS repository to rewrite — a repo reading, which is why the notification is registered here rather than skipped. ADR-0087, ADR-0032.
    • Done when: Grep every authored structural condition — BOTH edges[].condition and a node's config.condition (a decision node's predicate, and on a start node the trigger gate) — in defineStack({ flows }) sources, exported stacks and POST /api/v1/automation bodies, and every flow row in sys_metadata, for an envelope with no source key and for a source (or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the node key unswept, and the node key is the one with no schema in front of it. For each hit decide, per the replacement note, whether the condition was meant to branch (author the source) or to be unconditional (remove the key) — do not default to removal; on a start node removal opens the trigger gate rather than preserving it. Two proofs, and the second is the one that matters for stored rows. (1) For a stack authored in config files, objectstack validate is clean: it locates each offender with the EVALUATED_EXPRESSION_SOURCE_REQUIRED sentence — an edge at flows.N.edges.N.condition, and a node by the slot phrase the structural pass builds, e.g. node 'gate' (start) condition — and an ast-only envelope is also reported by the lint path as STRUCTURAL_CONDITION_SHAPE_REFUSAL, which is the sentence the node slot earns for that spelling as well. There is no CLI verb that lowers a stored row back into a config file, so this proof does not reach a flow that exists only in sys_metadata. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it (the three boot paths spell it [Automation] failed to register flow, [Automation] flow re-sync: failed to register flow and [Automation] cold-boot flow bind: failed to register flow), and its trigger is armed. That warn line IS the locator for a stored row: for an edge its issues[].path names edges[N].condition, and for a node the refusal carries that same slot phrase. A flow that boots without that warn is unaffected; every structural condition carrying a non-blank source parses byte-identically to before.
  • flow-edge-unresolved-or-repeated-refused — a flow edge whose source or target is not the id of a node in the graph that declares it — the flow's own nodes for a top-level edge, the region body's nodes for an edge inside a loop, parallel or try_catch region, so a top-level edge into a region node is one of them — and a later edge of the same graph with the same source, target, type, condition and branch label as an earlier one. Reachable wherever a flow is authored or stored: defineStack flows sources, defineFlow, an exported stack passed to objectstack validate, a flow saved from the Studio flow designer after a node was removed (its edges were left behind, and a node added later under the reused id picked them up), and a flow row already sitting in sys_metadata → an edge whose source and target are node ids declared in the same graph as the edge: re-point the endpoint at the node it was meant to reach, or delete the edge. Deleting a dangling edge changes nothing a run did, with one exception: a conditioned edge into a missing node still counted as the branch taken when its condition held, so a default sibling was passed over and, on an exclusive decision, the later conditioned siblings were skipped — where a flow relied on that, point the edge at a node that ends the branch. For a repeated edge, delete the later copy: the target then runs once per traversal instead of once per copy — a CHANGE of behaviour wherever the copies ran it more than once, which is the defect being removed. An edge meant to take its own route needs its own condition or branch label
    • Why not automatic: The engine resolves an edge's endpoints in the graph that declares it — traversal looks the target up there, and a region runs against a view of its own nodes and edges — and runs a target once per out-edge it selects. FlowSchema held node ids and edge ids unique and checked neither that an edge names a node of its graph nor that it is not a copy of another, so a draft holding an edge into a node it no longer had, or one edge three times, passed FlowSchema.parse, objectstack validate and the metadata save door, published with _diagnostics.valid: true, and ran: the dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy (one record update created three identical records). The parse now refuses both at every depth the region walk reaches: an endpoint at edges.N.source / edges.N.target (or the region path nodes.N.config.body.edges.M.target), naming the missing id and, when it is a node of another graph, that graph; a repeated edge at edges.N, naming the earlier copy. Repeated means the key the engine selects on — source, target, type, condition (its dialect and source) and branch label — so two nodes joined by edges with different conditions, a fault edge beside a default one, or approve and reject branches into one node stay legal. A region edge naming no node of its region was already refused at registration by the region analysis; the top-level half had no refusal anywhere. ⚠️ No D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a repeated edge changes how many times its target runs. ⚠️ Where such an edge already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack flows source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Grep every flow in defineStack flows sources, exported stacks and every flow row in sys_metadata — including the edges of each loop / parallel / try_catch region body — for an edge whose source or target is not the id of a node in the nodes list beside that edges list, and for two edges in one edges list with the same source, target, type, condition and label. Each refusal names the edge: FlowSchema.parse anchors a custom issue at edges.N.source, edges.N.target or edges.N (or the region path nodes.N.config.body.edges.M…), and objectstack validate prints the same path. For a dangling endpoint, point it at the node the edge was meant to reach or delete the edge; for a repeated edge, delete the later copy. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it (the three boot paths spell it [Automation] failed to register flow, [Automation] flow re-sync: failed to register flow and [Automation] cold-boot flow bind: failed to register flow) — that warn line is the locator for a row that exists only in sys_metadata. A flow whose edges all resolve in their own graph and repeat nothing parses and registers byte-identically to before.
  • flow-node-config-required-keys-refused — a flow node whose config leaves out a key its executor contract requires — objectName on get_record / create_record / update_record / delete_record, recipients on notify (and title when there is no template), url on http, function on script, flowName on subflow, collection and flowName on map, collection on a loop that has a body, branches on parallel, try on try_catch, and on screen each field name, each option value and label, and a lookup field reference — and a decision node whose conditions is not an array, holds a branch that is not an object, or holds a branch whose label is absent, null, blank or not a string; at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, a flow saved from the Studio flow designer (a node added and saved before it is configured; a decision branch row whose label cell is empty; a screen field row whose name cell is empty), and a flow row already sitting in sys_metadata → the missing key, written on the node's config — the value the node was meant to act on (objectName: 'account', url: 'https://…', collection: '{rows}', …). For a decision branch, the label of the out-edge the branch should take ({ label: 'approved', expression: 'record.amount > 1000' }, beside an out-edge labelled approved), conditions written as an array of such objects, and a bare predicate string moved under expression. To branch on the out-edges instead, delete conditions and put each predicate on its edge's condition. A legacy flat-graph loop (no body) needs no collection and is untouched
    • Why not automatic: A flow node's config is an open record, so what its executor requires was checked by no build door: FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate all admitted a node missing a key its executor contract requires, and the executor's own contract parse then refused the node on every run that reached it — the config is metadata, so no rerun could succeed. A decision branch with no label was worse: it never failed, the matched branch reported no label and traversal took EVERY out-edge, so the flow ran green down the wrong paths. All three doors now refuse these shapes through one judge, flowNodeConfigRefusals, which parses each builtin node's config against the very contract its executor parses against (getBuiltinNodeConfigContracts(), reconciled against the executors' own parse calls) and keeps only the keys left out — a present value of the wrong type and an undeclared key are judged where they were before — plus the decision branch shape its executor reads raw. A key a rule of the contract requires (a notify with no template needs a title; a lookup screen field needs its reference) is refused in the contract's own words. ⚠️ No D2 conversion: the platform cannot know the object, URL, collection, function or out-edge label the author left out, and no value it could write would keep what the flow did. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.config.<key> (nodes.N.config.fields.0.name, nodes.N.config.conditions.0.label, or the region path nodes.N.config.body.nodes.M.config…), objectstack validate prints the same path, and validateStackExpressions phrases it as node 'fetch' (get_record) config.objectName. For each hit write the key the node was meant to carry, per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it (the three boot paths spell it [Automation] failed to register flow, [Automation] flow re-sync: failed to register flow and [Automation] cold-boot flow bind: failed to register flow) — that warn line is the locator for a row that exists only in sys_metadata. A node carrying every key its contract requires parses and registers byte-identically to before, a decision with no conditions (or conditions: null, or an empty list) still routes by its out-edges, and a legacy loop with no body still needs no collection.
  • flow-predicate-slot-blank-string-refused — the two ledger predicate slots on a flow node — config.conditions[].expression on a decision node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility predicate) — authored as a string that is blank after trimming ('', ' ', a tab or a newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate, and a flow row already sitting in sys_metadata → the predicate the branch or field was meant to test, as non-blank bare CEL text (expression: 'record.amount > 10', visibleWhen: 'amount > 0'); or KEEP what the blank did. On a screen field, drop the visibleWhen key: an absent visibleWhen shows the field unconditionally, which is what a blank one already did at run time (the resume contract treated it as absent, and the renderer fell back to showing the field). On a decision branch, write expression: 'false': the evaluator answered the blank false, so the branch keeps its label and is still never taken. ⚠️ Not by dropping a decision's only branch: with no conditions the node routes by its out-edges alone, so the out-edge that branch labelled is no longer held back. On a structural condition removal differs again: dropping a blank condition turns a never-firing edge into an always-firing one (flow-edge-condition-evaluated-slot-source-required)
    • Why not automatic: Maintainer ruling A of 2026-09-13: the two sibling predicate slots refuse a blank string at authoring. Both slots are declared bare CEL text (z.string()) and both admitted a blank string at every door: the expression ledger resolver skipped it as "not authored", and AutomationEngine.evaluateCondition answered it false — so a decision branch carrying it was never taken, with nothing said at any layer, and a screen field carrying it was shown with its predicate ignored. An earlier fix had pinned that admission as correct because the two sides agreed. The ruling is that self-consistency between parser and evaluator is not a defence when the author's intent is silently dropped — the third instance of one rule, after the structural config.condition and a blank evaluated source. The blank is now refused at FlowSchema.parse, at AutomationEngine.registerFlow (which parses first) and at objectstack validate, all three through predicateSlotRefusal, leading with PREDICATE_SLOT_STRING_REFUSAL. ⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is where an author meant to write a rule, and the platform cannot tell a predicate somebody forgot from one they meant to delete. Keeping what ran is mechanical; writing the predicate is what the author intended; only the author knows which. ⚠️ Where such a blank already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0032.
    • Done when: Grep every flow node in defineStack({ flows }) sources, exported stacks and every flow row in sys_metadata — including nodes inside a loop / parallel / try_catch region body — for a decision node whose config.conditions[i].expression, or a screen node whose config.fields[i].visibleWhen, is a string that is empty after trimming. Each refusal names the node and the branch or field, which is the TODO's locator: FlowSchema.parse anchors a custom issue at nodes.N.config.conditions.I.expression (or …config.fields.I.visibleWhen, or the region path nodes.N.config.body.nodes.M.config…), and objectstack validate prints the same path; validateStackExpressions phrases it as node 'check' (decision) decision branch expression at config.conditions[0].expression. For each hit decide, per the replacement note, whether to write the predicate or to keep what the blank did. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it (the three boot paths spell it [Automation] failed to register flow, [Automation] flow re-sync: failed to register flow and [Automation] cold-boot flow bind: failed to register flow) — that warn line is the locator for a row that exists only in sys_metadata. A non-blank predicate parses and registers byte-identically to before, and a non-string in these slots keeps its own earlier refusal (at registerFlow and objectstack validate).
  • flow-script-subflow-config-undeclared-keys-refused — a script or subflow flow node whose config carries a key its executor contract does not declare — a typo (funtion), a key copied from another node type (a subflow timeoutMs written inside config, an approvers list on a script), or a key nothing reads (bogusKey). script declares function, inputs and outputVariable; subflow declares flowName, input and outputVariable. Never a retired script key (actionType, template, recipients, variables, script), which keeps its own path, and never a key on any other builtin node type, whose undeclared keys registration already judges against the node type descriptor. Reachable wherever a flow is authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata → the key the contract declares, or no key: rename a typo to the declared key it meant (function, inputs, outputVariable on a script; flowName, input, outputVariable on a subflow), move a value the function or the child flow should receive into inputs (script) or input (subflow), move a subflow timeout to the node itself ({ id, type: 'subflow', timeoutMs: 30000, config: { … } }), and delete a key nothing reads. The refusal carries the contract's own sentence, with its did-you-mean for a near miss
    • Why not automatic: The script and subflow executors (service-automation builtin/screen-nodes.ts, builtin/subflow-node.ts) parse the node's config against a strict contract (ScriptConfigSchema, SubflowConfigSchema) before they act, and refuse the node on an undeclared key. No door before the run judged one: registerFlow's undeclared-key check derives the declared set from the node type descriptor's configSchema, and these two descriptors publish none (the schemaless class, SCHEMALESS_NODE_CONFIG_SCHEMAS), while the build doors' executor-contract arm judged required keys and present values but held key membership back on the premise that registration judges it. So a script node carrying bogusKey passed FlowSchema.parse, objectstack validate and objectstack compile (which copied the key into the artifact), registered, and then failed every run that reached the node: the config is metadata, and no rerun could succeed. The one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first) and objectstack validate share (flowNodeConfigRefusals) now refuses such a key on these two types as node-config-refused-by-contract, anchored at the key, one refusal per key, in the contract's own words — the code the value half and the approval contract already use. Every other builtin keeps its undeclared keys where they were judged: at registration, against its descriptor, with that check's own prescriptions. decision is schemaless too, but its executor parses no contract, so an undeclared key there fails no run and stays unjudged. A retired script key keeps its tombstone path. ⚠️ A spelling the ADR-0087 D2 conversion flow-node-script-config-aliases or flow-node-subflow-flow-alias still rewrites at load (functionName, input on a script; flow on a subflow) is converted before the judge at every door that converts first; met by a direct FlowSchema.parse or defineFlow() it is refused like any other undeclared key, as the missing canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register; a defineStack({ flows }) source throws StackSchemaInvalidError for the whole stack; an artifact file is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the node and the key: FlowSchema.parse anchors a custom issue at nodes.N.config.<key> (nodes.N.config.bogusKey, or the region path nodes.N.config.body.nodes.M.config…), objectstack validate prints the same path, and validateStackExpressions phrases it as node 'summarize' (script) config.bogusKey. For each hit rename, move or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it — that warn line is the locator for a row that exists only in sys_metadata. A script or subflow node whose keys its contract declares parses and registers byte-identically to before.
  • flow-text-slot-single-brace-refused — flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a single-brace template token → a double-brace template hole, rendered by the formula template engine over the flow's variables: a variable path with an optional formatter, {{ record.name }}, {{ $error.message }}, {{ rows.0.subject }}, {{ record.amount | currency }}. A token no hole can spell is computed into a variable first, with an assignment node — arithmetic and functions as a CEL value envelope, the date macros as the value-slot spelling that still reads them, the run user's id as the CEL value envelope current_user.id — and written as {{ variable }}
    • Why not automatic: ADR-0032 Decision 3 fixes one template delimiter, double braces, and deletes the single brace: it collides with CEL map literals, and an author who meets both dialects in one flow mixes them. The 17.x interpolator and the template engine render the same text for a path holding a string, a number, a boolean, null, an absent key or variable, an ISO date string, an object or an array, but not for every value — a Date rendered JSON-quoted under the interpolator and as its ISO text under the engine, and a screen title, screen description or end message that was one token holding an object, an array or a Date rendered String(value) — so no conversion is lossless (ADR-0087 D2) and none is applied. Arithmetic, function calls, the date macros and the run-user paths have no hole spelling: a hole is a path with a formatter, never logic. A flow carrying a single-brace token in a text slot is refused at registration, by objectstack validate and by the node contract; a stored flow carrying one is skipped at boot with a warn naming it.
    • Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, with the double-brace spelling of every path token. Rewrite each slot as that spelling; for a token no hole can spell, add the assignment the refusal names and write its variable as a hole. Re-run the flow paths that send those notifications or show those screens and compare the text with the text the 17.x renderer produced — in particular any slot that renders a date value or a whole object.
  • flow-text-slot-unbound-dollar-root-refused — flows[].nodes[].config of a notify node (title, message), a screen node (title, description) and an end node (message) — a string, or the source of a template envelope, carrying a double-brace hole whose root is a dollar-named variable the flow engine does not bind, such as {{ $User.Id }} → a variable the run has, written as a hole. The run user's id is computed first, with an assignment node whose CEL value envelope reads current_user, the run's user (assignments: { by: { dialect: 'cel', source: 'current_user.id' } }), then written as {{ by }}; every other run-user path never resolved in any shipped run, and an email or a name is read from the user record by current_user.id. A variable the flow binds itself (a declared variable, an assignment target, an outputVariable, a try_catch errorVariable) is named without the dollar sign and written as {{ caught.message }}. The engine's own variables stay holes: {{ $error.message }}, {{ $record.name }}, {{ $runId }}, {{ $flowName }}, {{ $flowLabel }}, and a flat-graph loop's {{ $loopItems }} / {{ $loopIndex }}
    • Why not automatic: The dollar-named variables are the flow engine's own: it binds $record, $runId, $flowName, $flowLabel and $error, and a flat-graph loop binds $loopItems and $loopIndex. A hole over any other dollar name answers to no variable — {{ $User.Id }} looks like the run user and is not one, since the run user has no hole spelling. In 17.x the slot was a plain string read by the single-brace interpolator, which substituted the inner token and left a literal brace on each side; the 18 text slots render holes through the template engine, where such a hole renders nothing and the run reports success. It is now refused by the node contract, at registration and by objectstack validate, with the remedy its single-brace spelling gets; a stored flow carrying one is skipped at boot with a warn naming it. No D2 conversion exists: what the author meant the hole to read is not in the flow, and the template engine binds no new variable to answer it.
    • Done when: Run objectstack validate: it reports each refused text slot as expression-invalid at the node and the slot's key, naming the hole and its remedy. For a run-user hole, add the assignment the remedy names and write its variable as the hole; for a variable the flow binds under a dollar name, drop the dollar sign at the binding and in the hole. Re-run the flow paths that send those notifications or show those screens and confirm the text carries the value, with no stray brace and no missing fragment.
  • flow-trigger-record-credential-masked — the record and previous roots a record-change flow receives — a password or secret field, and an internal field, of the triggering record, on every object → read a credential through a privileged binder — the flow credential channel for an http node's signing secret, or a privileged server-side read such as the engine's resolveSecretField — never off record or previous; on those roots a set credential-class field now reads as the mask SECRET_MASK, an unset one as null, and an internal: true field is absent
    • Why not automatic: ADR-0100: a credential-class value leaves the engine only through a privileged dereference, and every generic channel serves the mask. The record-change trigger built a flow's record and previous from the engine's own write result, which keeps the stored row whole for privileged in-process callers, so a password field's plaintext, a secret field's stored handle and an internal field's value reached the flow — and from there its variables, a paused run's persisted state and that state's read doors. The trigger now projects both roots through the same helper every external write response uses: a credential-class field (secret, and password outside the exempt managedBy buckets) carries the mask, or null when unset, and an internal field is omitted. Every other field keeps its value, every other variable is untouched, and the engine's own write result, the stored row and the privileged read paths are unchanged.
    • Done when: No flow reads a password, secret or internal field off its trigger record or previous values expecting the stored value; a flow that needs a credential obtains it through a privileged binder; a start or edge condition that compared such a field against a literal is rewritten to test whether it is set (not null).
  • flow-value-slot-template-dialect-refused — flows[].nodes[].config of an assignment node (the assignments map, the legacy assignments array and the legacy bare config) and of create_record and update_record nodes (the fields map) — a string value, or a string anywhere inside an array or object value, carrying a single-brace template token, the run-user paths beginning $User. included → a CEL value envelope, { dialect: "cel", source: "…" }, evaluated to the value: a path is the same path (record.owner; a numeric segment becomes an index, items[0]; a variable whose name starts with $ is read through vars, vars["$error"].message), arithmetic is the same arithmetic with every integer divisor written as a double (round(x * 100) / 100.0), and text with holes is one concatenation ('Hello ' + o.name). The run user's id, $User.Id, is current_user.id — current_user is the run's user, or null when the run has none — and in a flow that can run without a user it is current_user != null ? current_user.id : null, which writes null where the template wrote nothing, so on update_record it clears a stored value the template left alone. Every other run-user path ($User.Email, $User.Name, …) never resolved in any shipped run: current_user carries only what the run holds (id, positions, organizationId, isPlatformAdmin), and an email or a name is read from the user record by current_user.id (a get_record node on sys_user). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal
    • Why not automatic: The interpolator and the CEL engine answer differently for every token spelling authored in flows, so no conversion is lossless (ADR-0087 D2) and none is applied. A path, an absent variable, key or list index wrote nothing under the template and fails the run under CEL; text with a null hole rendered nothing and CEL refuses + null; CEL divides two integers as integers, so round(x * 100) / 100 truncates 123.46 to 123. Where a value may be absent, which of nothing, null or a default the field should take is the author's decision — the template decided it silently. The run user's id was the run's userId under the template, and nothing in a run with no user (a schedule, a record change made by a system write); the flow CEL scope binds current_user to the run's user and to null in such a run, never a pseudo-user, so current_user.id fails there and its guarded form writes null. The other run-user paths read a user object no run carries, so they wrote nothing in every run. One spelling is kept with its old meaning, because CEL cannot write it yet: the date macros NOW() and TODAY() with a day offset (CEL yields a Timestamp, not the ISO text, and has no string form for one). A flow carrying a refused value is refused at registration, by objectstack validate and by the executor; a stored flow carrying one is skipped at boot with a warn naming it.
    • Done when: Run objectstack validate: it reports each refused value as expression-invalid at the node and the value's path, with the CEL spelling of its tokens. Rewrite each as that envelope; where a variable or key may be absent, guard it (has(record.owner) ? record.owner : null, has(vars.x) ? vars.x : null for a variable) or route around the node. For the run user, find which flows can run without one (a schedule, a record change a system write can make): there, guard current_user.id, or skip the node with a start condition or a decision on current_user != null where an update_record must leave the stored value alone. Re-run the flow paths that write those fields and compare the stored values with the ones the template wrote.
  • flow-write-node-stored-metadata-target-refused — a create_record, update_record or delete_record flow node whose config.objectName is the string sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body → Change metadata through the metadata API (PUT /api/v1/meta/:type/:name, the metadata protocol), where it is validated and its provenance is recorded. Delete the node, or point its objectName at the object the flow really means to write. Elevation (runAs, a system context) does not change this.
    • Why not automatic: FlowSchema accepted a create_record, update_record or delete_record node whose objectName names sys_metadata or sys_metadata_history, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their only writer, where a change is validated and its provenance recorded, and a flow is app-authored automation. The runtime enforces that at the node, refusing the write before it resolves a filter, computes a field or calls the data engine, under every run identity; but every authoring door still accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses it too, through the one judge FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate share (flowNodeConfigRefusals), with the runtime's prescription: objectstack validate, defineStack, compile, an artifact's parse, registerFlow and the metadata save door each name the node at nodes.N.config.objectName. The refused set is exactly the runtime's: one of those three write nodes, whose objectName is a string naming a stored-metadata table by exact name. A get_record node is outside it (a read is not a write), and so is a dynamic target, a {token} template or an expression envelope: the parse cannot read it as a name, and the run judges the name it hands the data engine. No authored flow writing either table was measured in this repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the node or deleting it each changes what the author wrote, and the runtime already never ran it. Where such a node already sits, the whole flow is refused: registered from sys_metadata at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register.
    • Done when: objectstack validate reports no issue at a flow node's config.objectName: no create_record, update_record or delete_record node names sys_metadata or sys_metadata_history. Every change those nodes made to metadata is made through the metadata API instead. Saving each formerly affected flow through the metadata API succeeds instead of answering a 422 that names config.objectName, and boot logs no failed to register flow warn for it.
  • form-field-public-picker-retired — view.form.sections[].fields[].publicPicker — the anonymous public-form record-search picker → No record search on an anonymous public form. For a choice from a fixed list, a select field with static options. For a choice of an existing record, the same form behind sign-in, where the lookup field renders with the signed-in user's access.
    • Why not automatic: The D2 conversion form-field-public-picker-removed deletes publicPicker from every form field, and the delete is lossless in effect: the block's only reader was the anonymous lookup route, which is gone, and the public-form resolve route now leaves lookup, master_detail and user fields off the anonymous rendering whatever the row carries. What the strip cannot decide is the visitor's path. A public form that used the picker let an anonymous visitor search and pick a record; after the upgrade that field is simply absent from the form, so a submission arrives without the value. Whether the choice was really from a small fixed set (a select with static options), or needs a real record and therefore a signed-in user, is a product decision only the author can make.
    • Done when: No form field carries publicPicker; the parse refuses it. Every public form that had carried one either replaces the lookup field with a select field whose static options list the allowed choices, or is served behind sign-in, or the author has confirmed the form works without the value. Fetching the public form anonymously (GET /forms/:slug) shows no lookup, master_detail or user field in its sections.
  • form-view-option-default-retired — view.form.sections[].fields[].options[].default — the per-option pre-selection on a form view's own option list → The object field's own option list, where default is enforced: default: true on that field's options entry, or the field-level defaultValue.
    • Why not automatic: The D2 conversion form-view-option-default-removed deletes default from every option of every form-view field it reaches, and the delete is lossless: nothing on the form path ever read it — the insert-path default falls back to the OBJECT definition's options, and no form renderer seeds a value from a form view's. So a form that marked an option as default never pre-selected it, and still does not. The judgment is in the replacement. The form-view key was scoped to ONE form; the object field's default applies on EVERY insert path — every form of that object, the API, imports. Moving the marker there makes the form do what its author wanted and also changes what records created elsewhere receive when the value is omitted. Only the author can say whether that wider default is correct, or whether the pre-selection should be dropped.
    • Done when: No form-view option carries default; the parse refuses it. For each form field that had marked one: either the object field now declares the default and the author has accepted it for every insert path — a record created through the form or the API with the field left empty is stored with that value — or the author has decided the form needs no pre-selection and the object field is unchanged.
  • form-view-subform-columns-closed — view.form.subforms[].columns[] and view.formViews.<key>.subforms[].columns[] — the form view's inline grid columns, which used to accept any value → each entry is the strict, name-keyed inline grid column a relationship field's inlineColumns takes — { name, label?, type?, … }, where { name } alone hydrates the rest from the child object's field. Write name where a column said field (or fieldName, key); delete scale from a column declaring type: 'currency'; delete any key the column schema does not declare.
    • Why not automatic: Both carriers feed the one console grid, which reads only the keys the column schema declares and keys a column by name alone. On the form view the columns were never judged, so a mis-keyed column published clean and drew a blank grid column, and a key the other carrier refuses — scale on a currency column, under the maintainer's ruling of 2026-09-23 (option B, scale retired from the currency type) and the remedy ruled on 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — published green here. The carrier now references the column schema, so every rule it holds applies here too, with its own prescription. Only the field spelling is converted mechanically — by the conversion form-view-subform-columns-canonicalized, which rewrites stored rows and assembled artifacts and lists the edit under os migrate meta, while an author writing field meets the refusal. Which column an unknown key or a mixed field/name entry meant is the author's call — a conversion that dropped the key would accept on every load what the parse now refuses. Population measured at the change, on origin/main cb4c31dd52: zero authored subforms in the repository (the showcase derives its master-detail grids from the data model instead), against one authored inlineColumns block as the control. Deployed metadata NOT MEASURED.
    • Done when: Every view in the stack parses: objectstack validate and a view parse report no issue on a subforms[].columns[] path. Every column entry is an object carrying name, no entry carries field, fieldName or key, and no column declaring type: 'currency' carries scale. Each column name names a field of the subform's childObject, and the master-detail grid renders a value — not a blank cell — in each column for a row that has one.
  • hook-body-stored-metadata-target-refused — hook.object naming sys_metadata or sys_metadata_history, as the string or as any member of the list, on a hook that carries a body → Change metadata through the metadata API (PUT /api/v1/meta/:type/:name, the metadata protocol), where it is validated and its provenance is recorded. Delete the hook, or point its object at the tables the logic really concerns. Elevation (runAs, a system context) does not change this.
    • Why not automatic: HookSchema accepted a hook whose body targets sys_metadata or sys_metadata_history, the tables that hold stored metadata. The maintainer ruled (2026-10-03) that an app-authored body may not touch those tables: for a body, the metadata protocol is their only writer, where a change is validated and its provenance recorded. The runtime enforces that where a body hook becomes a handler, refusing such a hook at registration so that it never runs, but every authoring door still accepted it: the metadata save door answered 200, and the author learned otherwise only from a server log. The parse now refuses it too, with the runtime's own prescription, so objectstack validate, defineStack, compile, an artifact's parse and the metadata save door (a 422) each name the target at object, or at the list member. The refused set is exactly the runtime's: a hook carrying a body, in any form, whose object names a stored-metadata table, as the string or as any member of the list, and one such member refuses the whole hook. A hook with no body (a code handler, which is how the platform writes its own hooks) and the wildcard '*' are outside it, as they are at registration: a wildcard names no stored-metadata table, so it binds, and the runtime never runs its body for those tables' events. No authored hook targeting either table was measured in this repository, its examples or hotcrm. There is no mechanical rewrite: retargeting the hook, dropping its body or deleting it each changes what the author wrote, and the runtime already never ran it. A stored hook row of this shape still loads, now with a [metadata_spec_invalid] warning and a _diagnostics badge, and is still never bound.
    • Done when: objectstack validate reports no issue at a hook's object path: no hook that carries a body names sys_metadata or sys_metadata_history in its object, as the string or in the list. Every change those hooks made to metadata is made through the metadata API instead. Saving each formerly affected hook through the metadata API succeeds instead of answering a 422 that names object, and boot logs no binding refusal naming one of those tables for a hook.
  • hook-register-undispatched-lifecycle-event-refused — engine.registerHook('beforeFindOne' | 'afterFindOne' | 'beforeCount' | 'afterCount' | 'beforeAggregate' | 'afterAggregate', handler) → for the findOne pair, register on 'beforeFind' / 'afterFind' — they already fire for findOne; for the count and aggregate pairs there is no hook seam at all, so move the logic to engine.registerMiddleware(fn) and read ctx.operation === 'count' | 'aggregate', composing the predicate onto ctx.ast.where
    • Why not automatic: registerHook took event: string and, for a name outside the dispatched set, warned and then REGISTERED the handler anyway. Six of those names are inside the engine's own lifecycle namespace — (before|after) x OperationContext['operation'] minus the eight the engine dispatches — so an author writing one of them believes they are subscribing to an engine lifecycle event, and what they get back is an inert declaration: ADR-0078's prohibited fourth state (parsed, unmarked, silently inert) on an authorable seam.

The measured consequence is a data-visibility one, which is why this is not a cosmetic warning. A downstream consumer registered READ FILTERS on beforeFindOne and beforeCount, expecting them to scope single-record reads and list totals; they sat inert through every boot behind about forty warning lines. findOne was still filtered — beforeFind covers it — so the mistake gave no signal there. count was not: a limited list answered a total counting rows the caller could not see. aggregate was not either: a groupBy was not narrowed at all. A filter that was supposed to narrow visibility and silently did not run is a guardrail the author believes they armed.

Refused at REGISTRATION rather than repaired on the dispatch side. Making count() and aggregate() dispatch hooks would widen what a hook may intercept — a different and much larger decision — and it would also be the wrong seam: read authorization and row filtering are the middleware chain's job, which is what HookEvent in @objectstack/spec already says and what count() already honours (its AST rides the operation context precisely so the security and sharing middlewares can scope it). The refusal names the per-seam repair in its own message, because "this never fires" alone cannot tell the two seams apart: one is a rename, the other is a different API.

The refusal is scoped to those six names, not to everything outside the dispatched set. triggerHooks is public, so a plugin dispatching its own event under a name outside the engine's vocabulary ('myPlugin:flush') is a legitimate reading — that is why the change that collapsed the hook taxonomy to the eight dispatched events made this branch a warn — and it still warns and still registers. The population is DERIVED from the operation union rather than typed out, so a new engine verb widens it without an edit; a hand-written list of refused names would be this same defect one layer up.

This is a RUNTIME registration API, not stored metadata, so — like hook-register-empty-object-target-refused at the previous step — there is no sys_metadata row for the D2 chain to rewrite, and the ledger entry is the notification channel. The metadata door was never open on this axis: HookSchema.events is z.array(HookEvent), and HookEvent enumerates exactly the eight dispatched names, so no authored or stored hook could ever carry one of the six. The exposure was entirely on the code door. ADR-0078.

  • Done when: No registerHook call site passes beforeFindOne, afterFindOne, beforeCount, afterCount, beforeAggregate or afterAggregate. Every read filter that was written against one of those names has been moved: the findOne pair to beforeFind / afterFind, the count and aggregate pairs to a middleware registered with engine.registerMiddleware. Boot completes with no "[ObjectQL] Hook '...' is an engine lifecycle event name the engine never dispatches" throw, and any list total or groupBy that was expected to be scoped is scoped by a middleware rather than by a hook.
  • hook-timeout-unit-in-key — hook.timeout — the per-invocation time limit of a data hook → timeoutMs — the same limit, in milliseconds, with the unit in the key name.
    • Why not automatic: The D2 conversion hook-timeout-to-timeout-ms renames timeout to timeoutMs in author sources and on stored hook rows, keeping the value, and the rename is lossless: the key always meant milliseconds, and the conversion leaves an already-canonical timeoutMs alone and refuses a pair that disagrees. The judgment is the one the rename exists for. The unit used to live only in the key's description, beside body-level keys that spelled theirs, so an author who wrote a seconds value — timeout: 30 meaning thirty seconds — got a limit of thirty milliseconds and no error, and the rename carries that 30 over unchanged. Only the author knows which unit they meant, so each value needs reading once. A pair left unconverted because the two spellings disagree needs the author to choose, and code that builds or reads a hook definition in TypeScript is outside the chain's reach.
    • Done when: No hook carries timeout; the parse refuses it with the rename. Every timeoutMs value is the limit the author intends expressed in milliseconds — a hook meant to be allowed thirty seconds reads timeoutMs: 30000. A hook that runs longer than its timeoutMs fails with a timeout at that limit, and one that finishes inside it completes as it did before the upgrade. No code reads or writes timeout on a hook definition.
  • hot-reload-inert-state-strategies-retired — ``HotReloadConfig.stateStrategyvalues 'disk' and 'distributed', plus theHotReloadConfig.distributedConfigkey and theDistributedStateConfigdef it carried (3 exported names:DistributedStateConfigSchema/DistributedStateConfig/DistributedStateConfigParsed) → 'memory' for in-process state preservation across a reload, or 'none' to disable it — the two values PluginStateManager actually implements. There is no in-tree replacement for durable or distributed plugin state: persist it in the host, which owns the process lifetime these strategies pretended to outlive. Real disk or distributed persistence returns only via the ENFORCE route of ADR-0049 — the implementation first, the declaration with it.
    • Why not automatic: ADR-0049 enforce-or-remove, applied one level INSIDE the library the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept. That ruling retired the authorable lifecycle-config container and deliberately kept HotReloadConfigSchema as a host-driven library parameter type; this card measured the kept vocabulary's own remainder and found the same defect in it. Measured at cdbd9204b6 with a firing positive control (stateStrategy resolves to real readers in core/src/hot-reload.ts, so the scan sees readers): the 'disk' and 'distributed' arms of PluginStateManager.saveState both wrote to the SAME in-memory Map as 'memory' — the in-source comments said 'memory fallback' — and announced the substitution at DEBUG level only, so a host that asked for durable or cluster-replicated state got process-local memory and no error: state that does not survive the restart it was configured to survive. distributedConfig had ZERO readers anywhere (every reference inside packages/spec itself plus the generated reference page; nothing in objectui), so an author could name a Redis endpoint, a TTL and a replication factor and nothing ever opened a connection — the shape of the plugin sandboxing / integrity / approval config that was never wired to anything (an exported schema no runtime reads is read as a capability), sharpened by cluster-persistence vocabulary an AI author (ADR-0033) reads as proof the capability exists. The key left with the enum value its own doc comment named it "required" for, and DistributedStateConfig was its orphan value schema. Two routes in one card because the surface has two shapes: an enum-VALUE narrowing is invisible to the four ratchets (the def still emits), so its prescription hangs on the enum's own error map dispatched by issue.input (the crypto.hash / managedBy: 'system' precedent); the whole-def removal MUST move them, and that movement is its own evidence. No D2 conversion and no tombstone: HotReloadConfig is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses HotReloadConfigSchema outside its own unit test — so there is no authored document to rewrite and no one who could receive a parse-time prescription. Route 3, the shape of the dynamic plugin-loading family's removal and of that lifecycle-config ruling: this entry IS the declaration.
    • Done when: No host passes stateStrategy: 'disk' or 'distributed' to HotReloadManager.registerPlugin. TypeScript hosts cannot: HotReloadConfigParsed['stateStrategy'] is now 'memory' | 'none', so either value is a compile error at the call site. JavaScript hosts, and config that arrived as JSON, get a loud registration-time refusal carrying the prescription — an ADR-0112 envelope (code: VALIDATION_ERROR, status: 400) thrown BEFORE the enabled check, so a disabled config cannot smuggle the false declaration through. No import of DistributedStateConfigSchema, DistributedStateConfig or DistributedStateConfigParsed from @objectstack/spec or @objectstack/spec/kernel survives — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/plugin-lifecycle-advanced-retirement.test.ts. ⚠️ Runtime state behaviour is UNCHANGED for every config that worked: 'disk' and 'distributed' already stored to memory, so a host that migrates either to 'memory' keeps byte-identical behaviour — what changes is that the two spellings which never described what happened are now refused instead of silently honoured. That ruling's keep itself stands: HotReloadConfigSchema, PluginStateSnapshotSchema and the health vocabularies still export from ./kernel, and HotReloadManager / PluginHealthMonitor still export from @objectstack/core with their tests green.
  • hot-reload-watch-placeholder-retired — ``HotReloadConfig.watchPatterns, and the HotReloadManager.startWatching placeholder that read it → Run your own watcher and call HotReloadManager.scheduleReload(pluginName, reloadFn) when a file changes — that is the debounced integration point this class actually implements, and it is unchanged. Declare your globs wherever your watcher reads them; there is no in-tree replacement for the key, because file watching is the HOST's job in this host-driven library. The platform already depends on chokidar in @objectstack/metadata, @objectstack/metadata-fs and @objectstack/cli — never in @objectstack/core — so a host has a working model to copy.
    • Why not automatic: ADR-0049 enforce-or-remove, applied one symbol over from the inert 'disk' / 'distributed' state strategies retired in the same file, and on the same per-key test. HotReloadManager.startWatching contained NO watcher: its whole body was a guard plus logger.info('File watching started', { patterns }) above an in-source note saying real watching "would require chokidar or similar / This is a placeholder for the integration point". watchHandles was only ever read, deleted, iterated and cleared and NEVER set, so stopWatching's cleanup branch and the teardown loop over its keys were structurally UNREACHABLE rather than merely untaken (measured with a firing positive control: reloadTimers.set resolves a real writer in the same file and the same scan; watchHandles.set resolves nothing anywhere). So watchPatterns had no reader that ACTED on it — its only two uses were log lines — and an author could declare a glob while no file change could ever trigger a reload. This is the shape of the plugin sandboxing config that was never wired to anything, with the volume turned up: the inert state-strategy fallback at least announced itself at DEBUG, whereas this said "File watching started" at INFO — positive confirmation of a capability that did not exist, which an operator, or an AI author (ADR-0033), reads as proof and stops looking. Neither of the other two ADR-0049 states was available: ENFORCE would build for a caller that does not exist (no runtime composes HotReloadManager — only its own unit test and core/examples/phase2-integration.ts construct it, the same fact that decided the state-strategy retirement's route), and EXPERIMENTAL requires a roadmap, where a scan of every planning doc returned ZERO mentions of hot-reload file watching against 145 control hits in the same files. Route 3 again: HotReloadConfig is not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing in the tree parses HotReloadConfigSchema outside its own unit test — so there is no authored document to rewrite and nobody who could receive a parse-time prescription, so there is no D2 conversion either — it would be a transform with no seam that ever runs. The key is TOMBSTONED rather than deleted, and the BUILD is what decided that: the plain deletion was tried first and gen:schema gate (a) refused it, because HotReloadConfigSchema is not .strict() and a bare deletion would be a silent strip (the failure measured when a field key pruned from a non-strict schema still parsed successfully and simply vanished, ADR-0104) — the very defect being retired, one layer down. The state-strategy retirement could take route 3 because what left there was a whole DEF; a key leaving a SURVIVING def has no such exit. This entry IS the declaration.
    • Done when: No host passes watchPatterns to HotReloadManager.registerPlugin, and no host calls HotReloadManager.startWatching. TypeScript hosts cannot do either: watchPatterns is typed never by the tombstone, and startWatching returns never. JavaScript hosts, and config that arrived as JSON, get a loud refusal carrying the prescription — an ADR-0112 envelope (code: VALIDATION_ERROR, status: 400), thrown for a leftover watchPatterns BEFORE the enabled check so a disabled config cannot smuggle the false declaration through, and thrown unconditionally from startWatching so the placeholder can no longer report success. startWatching is kept as a throwing door rather than deleted precisely so that caller meets a prescription instead of a bare TypeError: not a function. Runtime reload behaviour is UNCHANGED for every config that worked: nothing was ever watched, so nothing that used to happen stops happening — registerPlugin, scheduleReload, reloadPlugin and state preservation are untouched, and stopWatching keeps the half that always did something (it cancels a pending debounced reload; its unreachable watchHandles branch left with the placeholder). What the maintainer's 2026-08-25 ruling on the advanced plugin-lifecycle config kept still stands: HotReloadConfigSchema and PluginStateSnapshotSchema still export from ./kernel, and HotReloadManager / PluginHealthMonitor still export from @objectstack/core with their tests green.
  • identity-api-key-schema-retired — identity.apiKey (the whole of ApiKeySchemain identity/identity.zod.ts — 1 def, 3 exported names:ApiKeySchema, ApiKey, ApiKeyParsed) → (removed — there is no replacement schema, because the deleted one never described the real table. The single declaration of sys_api_key is the ObjectSchema in @objectstack/platform-objects (identity/sys-api-key.object.ts): columns name, prefix, user_id, active_organization_id, scopes, expires_at, last_used_at, revoked, key, id, created_at, updated_at, snake_case, revoked as the kill switch — not enabled. Rows are minted by POST /api/v1/keys (runtime/src/domains/keys.ts) and verified by core/src/security/api-key.ts, keyed by the osk_ prefix. Per-key rate limiting returns only via the ENFORCE route of ADR-0049 through a new ADR — the executor first, the vocabulary second)
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-15, disposition B: delete the schema. ApiKeySchema documented better-auth's apiKey PLUGIN schema — a plugin this platform does not load (plugin-auth/src/managed-extension-fields.ts states the table is hand-rolled ObjectStack): start and lastRefetchAt name columns that do not exist; enabled inverts the real revoked column's polarity; rateLimitEnabled / rateLimitTimeWindow / rateLimitMax / remaining advertise a per-key rate-limit capability nothing implements (the sharpest PD #10 instance — a reader can reasonably conclude API keys support rate limiting); permissions and metadata have no columns; organizationId is camelCase fiction next to the real snake_case active_organization_id. Zero consumers measured (08-14, re-verified at the retirement's base commit): only its own unit test, the export snapshots, the generated reference page and a prose mention in cloud/developer-portal.zod.ts (corrected in the same PR — the marketplace-key plan it gestured at is ruled NOT live). One table had two declarations and the published one was fiction; the generated reference page rendered it faithfully, which is how the defect surfaced as a docs card. With no carrier key and no authored document there is nothing to tombstone and no seam for a D2 conversion: route 3, the shape of the earlier removals of the dynamic plugin-loading family, the ui/ interaction configs, the widget / i18n shapes, five declared-but-inert surfaces and two credential-bearing schemas no sys_metadata door reached — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration.
    • Done when: No code imports ApiKeySchema, ApiKey or ApiKeyParsed from @objectstack/spec or @objectstack/spec/identity — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in identity/api-key-retirement.test.ts). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door, so no document could ever carry it. UserSchema / AccountSchema / VerificationTokenSchema and the organization module survive unchanged (the ruling accepts the sibling asymmetry deliberately), and the sys_api_key ObjectSchema in @objectstack/platform-objects still declares the real column set (pinned in sys-api-key-single-declaration.test.ts). ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the schema, so removing it removes no behaviour — mint and verify work byte-identically before and after.
  • incident-response-deadline-keys-retired — incident-response deadline keys: IncidentResponsePhase.targetHours, IncidentNotificationRule.withinMinutes/regulatorDeadlineHours, IncidentNotificationMatrix.escalationTimeoutMinutes, IncidentResponsePolicy.triageDeadlineHours/retentionDays`` → nothing to re-declare — delete the keys. No incident-response engine exists on the platform: nothing tracks a phase against a clock, sends or times an incident notification, notifies a regulator, walks the escalation chain on a timer or sweeps incident records on a schedule, so there is no live mechanism to declare a deadline to. Retention of stored records is the object-level lifecycle block (ADR-0057), declared on the object that stores the records and enforced by the LifecycleService — not a number on this policy document
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Six hour/minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write triageDeadlineHours: 4 and reasonably expect the platform to escalate after four hours — and read by NOTHING: the schemas are exported from @objectstack/spec/system, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the six carried defaults (30 minutes, 1 hour, 2555 days) that were materialized into every parsed document without ever being consulted. A compliance-shaped deadline that fails silently is the worst form of the declared-but-unenforced shape ADR-0049 names; tagging it [EXPERIMENTAL — not enforced] was the fallback the ruling did not take. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent).
    • Done when: No IncidentResponsePhase, IncidentNotificationRule, IncidentNotificationMatrix or IncidentResponsePolicy literal — standalone or nested in an Incident — carries targetHours, withinMinutes, regulatorDeadlineHours, escalationTimeoutMinutes, triageDeadlineHours or retentionDays. TypeScript authors get the refusal at compile time (each key is typed never); a value reaching the parse is refused with the prescription (invalid_type at the path of the key). Parsed documents no longer carry the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour.
  • incident-response-family-retired — the incident-response family, retired whole: the eight defs system/Incident, system/IncidentCategory, system/IncidentNotificationMatrix, system/IncidentNotificationRule, system/IncidentResponsePhase, system/IncidentResponsePolicy, system/IncidentSeverity and system/IncidentStatus, and every name system/incident-response.zod.ts exported from @objectstack/spec/system (the eight *Schema consts, their z.input aliases and the three *Parsed aliases) → nothing to re-declare — no incident-response engine exists on the platform, so there is no working configuration to migrate to. Nothing classified, tracked, escalated or notified an incident and nothing notified a regulator; a compliance record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine (validation, permissions, the object-level lifecycle block under ADR-0057). If incident response becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Eight defs and roughly forty declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from @objectstack/spec/system, mounted by no stack.zod.ts key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded), over examples/** and skills/**, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. Several keys were boolean capability claims of exactly the shape ADR-0049 names — IncidentNotificationRule.notifyRegulators, IncidentResponsePolicy.requirePostIncidentReview — so an author (very often an AI, ADR-0033) could write notifyRegulators: true, parse clean, and hold a compliance promise the platform never kept, with no error and no feedback. Tagging the family [EXPERIMENTAL — not enforced] was the fallback the ruling did not take: it is a human-only signal, and an AI generating from the schema still writes the key and believes it. The deadline-key tombstones of the 2026-09-02 per-family ruling (six sites, RETIRED_KEYS_BY_MAJOR[18], D3 incident-response-deadline-keys-retired) leave with their defs' source; their registry entries stay as history. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent), and with no carrier key there is no shape on which a tombstone could sit.
    • Done when: No code imports IncidentSchema, IncidentCategorySchema, IncidentNotificationMatrixSchema, IncidentNotificationRuleSchema, IncidentResponsePhaseSchema, IncidentResponsePolicySchema, IncidentSeveritySchema or IncidentStatusSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. The eight defs are absent from json-schema.manifest/system.json, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour.
  • inline-grid-column-currency-scale-refused — object.fields.<name>.inlineColumns[].scale on an inline grid column that declares type: 'currency'— any declared value,scale: 0included, computed or not.scaleon anumbercolumn is untouched. A column that declares notypeis judged as the type it renders as: over acurrencyfield of the child object it is entryinline-grid-column-identity-only-currency-scale-refused`` → no scale on a currency inline grid column. DELETE the key — that is the whole migration: a currency amount's decimal places are its currency's, not a column setting. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key.
    • Why not automatic: The maintainer's ruling of 2026-09-23 (option B) retired scale from the currency field type, and the ruling of 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) worded the remedy. Neither reached the inline grid column, the strict mirror of the console grid's column, which still offered per-column decimals on a currency column; triage read the column as inherited from both rulings, so InlineGridColumnSchema now refuses the key on a column declaring type: 'currency' at parse, with the field refusal's first sentence and remedy. ⛔ No alias and no grace window, per ruling B. NOT mechanically converted, deliberately, for the reason the field entry field-currency-scale-refused gives: a conversion that dropped the key would accept it on every load, which is the grace window the ruling refused; the refusal names the key and its one-line fix instead. The same change rewords the column's prefix description: it replaces the resolved currency's symbol and has no default (the grid no longer falls back to a fixed yen sign). Reach: the column schema judges only a DECLARED column type — a column that declares none takes its type from the child field when the console hydrates it, which the schema cannot see; defineStack judges that column instead (entry inline-grid-column-identity-only-currency-scale-refused). Population measured at the change, on origin/main 1c8b320a89: one authored inlineColumns block in the tree (the showcase invoice, seven identity-only columns, none declaring type or scale), no platform object, skill, documentation example or JSON fixture declaring an inline grid column at all, and one test fixture carrying scale: 2 on a currency column, re-judged in the same change. Deployed metadata NOT MEASURED.
    • Done when: Every field in the stack parses: an ObjectSchema parse and objectstack validate report no issue on an inlineColumns[].scale path of a column declaring type: 'currency'. A currency column that carried scale no longer declares it, and a diff of the column shows that one line deleted and no key added. number columns keep their scale, and so does a column declaring no type unless it names a currency field of the child object (entry inline-grid-column-identity-only-currency-scale-refused); a column's prefix is still accepted on a currency column.
  • inline-grid-column-identity-only-currency-scale-refused — object.fields.<name>.inlineColumns[].scale and view.form.subforms[].columns[].scale (and each formViews entry) on a column that declares NO typeand whosenameis acurrencyfield of the child object — any value,scale: 0included. A column declaringtype: 'number', and a column over a field of any other type, keep scale`` → no scale on the column. DELETE the key — that is the whole migration: the column renders as a currency column, and a currency amount's decimal places are its currency's. The currency's ISO 4217 minor unit decides how the cell displays the amount and the width a computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under any other key, and do not add type: 'number' to keep it on a currency amount.
    • Why not automatic: The refusal of scale on a currency inline grid column (entry inline-grid-column-currency-scale-refused, under the maintainer's rulings of 2026-09-23, option B, and 2026-09-24, option 乙) reached only a column that DECLARES type: 'currency', because the column schema cannot see the child field. An identity-only column — the recommended form — over a currency field renders as a currency column all the same, so it published green carrying the refused key, and the console ignored it. defineStack's cross-reference check, which holds the child object's fields, now judges such a column as the type it renders as and refuses it with the column schema's own message. Reach: the child object must be declared in the same stack; a column naming no field of it, or a subform whose child object comes from another package, is not judged there. Population measured at the change, on origin/main cb4c31dd52: one authored inlineColumns block (the showcase invoice, seven identity-only columns, none carrying scale) and zero authored subforms. Deployed metadata NOT MEASURED.
    • Done when: objectstack validate and defineStack report no cross-reference finding on an inlineColumns[].scale or subforms[].columns[].scale path. A column that carried scale over a currency child field no longer declares it, and a diff of the column shows that one line deleted and no key added. Columns over number fields, and columns declaring type: 'number', keep their scale.
  • job-timeout-unit-in-key — job.timeout — the per-attempt time limit of a scheduled job → timeoutMs — the same per-attempt limit, in milliseconds, beside the sibling retryPolicy.backoffMs that already spelled its unit.
    • Why not automatic: The D2 conversion job-timeout-to-timeout-ms renames timeout to timeoutMs in author sources and wherever the chain is replayed, keeping the value, and the rename is lossless: the key always meant milliseconds. The judgment is whether the author knew that. The unit lived only in the description while retryPolicy.backoffMs beside it spelled its own, so one job definition carried two conventions; a seconds value copied in — timeout: 300 for a five-minute job — became a 300-millisecond limit with no error, and the rename carries the 300 over unchanged. A limit that short fails every attempt and burns the retry budget, which is easy to misread as a flaky job. Only the author can say which unit each value was written in, and code that builds job definitions in TypeScript is outside the chain's reach.
    • Done when: No job carries timeout; the parse refuses it with the rename. Every timeoutMs value is the per-attempt limit the author intends in milliseconds — a job meant to be allowed five minutes reads timeoutMs: 300000. An attempt that runs past its timeoutMs fails with a timeout and is retried under retryPolicy, and an attempt that finishes inside it succeeds as before. No code reads or writes timeout on a job definition.
  • kernel-compatibility-matrix-estimated-migration-time-unit-in-key — CompatibilityMatrixEntry.estimatedMigrationTime, the migration effort estimate whose unit lived only in a source JSDoc (kernel/plugin-versioning.zod.ts) → estimatedMigrationTimeHours — rename the key AND state the unit in the describe; the value (hours) is unchanged
    • Why not automatic: Maintainer ruling A of 2026-09-17 on the last two duration keys no closed duration type could express (this one in hours, the other in fractional seconds): rename the key and record an ADR-0087 conversion-layer entry, with no new closed type and no narrowing of anything already stored. The key said "Estimated migration time in hours" in a source JSDoc and carried no .describe() at all — the JSDoc-channel shape (a unit stated only in a source comment the reference page never prints), one def over. The JSDoc stops at the source file; .describe() is what content/docs/references/** renders, so the published page printed a bare number directly beside migrationComplexity, whose scale IS named (trivial/simple/moderate/complex/major). A reader comparing "major" with "40" had no way to know whether 40 was minutes, hours or days. The remedy is BOTH halves, and the second is not optional: renaming alone would leave the two channels that name the unit — the key name and a source comment — agreeing about something the published page does not print, which check:duration-unit-keys refuses as unit-in-jsdoc-not-in-describe (that agreement shape — a unit in the key name and the JSDoc, none in the describe — was ruled an offence on 2026-09-18). So the unit moves INTO the describe and the key name carries it too. HOURS is kept rather than converted to seconds: the value is unchanged, the ruling forbade narrowing, and an effort estimate is authored in hours by the human who writes the plugin manifest. Tombstoned with retiredKey(): CompatibilityMatrixEntrySchema is a plain z.object, not strict, so a bare deletion would strip the old spelling in silence and a manifest would lose its one effort figure with no error anywhere. Why a semantic entry and not a D2 conversion: a compatibility matrix is a plugin-published version manifest — stack.zod.ts declares no collection of them and it is not a registered metadata kind stored as a sys_metadata row — so the chain has no seam that sees one. ADR-0087.
    • Done when: Every plugin manifest that declares a migration estimate spells estimatedMigrationTimeHours and every consumer reads that key. Authoring estimatedMigrationTime fails to compile (input type never) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: estimatedMigrationTimeHours: 8 is the same eight hours estimatedMigrationTime: 8 was, the key stays optional and stays a bare z.number() — a sweep that added .int() or adopted a closed duration type has narrowed a value the ruling refused to narrow. The migration is proved correct when the reference page for CompatibilityMatrixEntry prints the unit rather than a bare number, and when check:duration-unit-keys reports the key as satisfied rather than listing it unjudged. testCoverage on the same shape is a PERCENTAGE, not a duration, and does not move.
  • kernel-context-preview-mode-retired — context.mode — the value 'preview' left the RuntimeMode enum — and context.previewMode, the whole PreviewModeConfig block it keyed (autoLogin / simulatedRole / simulatedUserName / readOnly / expiresInSeconds / bannerMessage, declared on KernelContext and on the TenantRuntimeContext extension). The exported PreviewModeConfigSchema / PreviewModeConfig / PreviewModeConfigParsed names left with the def → nothing declarative — the capability the block described was never implemented by any layer, so there is no working configuration to migrate to. Preview/demo DEPLOYMENTS belong to the deployment layer, which owns auth per-project (ArtifactKernelFactory in the cloud distribution); the OS_PREVIEW_MODE environment variable stays exactly as it is — deployment ROUTING (widening the trusted-origin list for preview subdomains), unrelated to identity. If a preview experience becomes a product capability it re-declares fresh, with the production-posture hard-refusal as the first-landed half (as the removal ruling recorded)
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-27 (Option A: remove). The declaration was the sharpest declared-≠-enforced shape on a SECURITY surface: the schema promised "bypass auth, simulate admin identity" and named a production guard "the runtime must enforce", and NO code path implemented either half. Measured zero consumers in all three repos, each leg with positive controls: objectstack — no runtime branches on the mode; the only non-declaration hits for RuntimeMode or mode === 'preview' are the schema unit test, a type-alias pin and a measurement-test comment (re-verified at dispatch, 2026-08-27, origin/main 15bf9e8). objectui — zero consumers, measured when the removal was ruled. cloud — a census closed 2026-08-26: OS_PREVIEW_MODE there is a routing-only switch (the same switch this repository's serve.ts reads only to add preview-domain wildcards to better-auth's trusted origins); RuntimeMode has zero hits repo-wide; the positive control ArtifactKernelFactory (where serve.ts predicted preview auto-login would live if it existed) has 20+ hits and never touches previewMode. An author — very often an AI (ADR-0033) — could write the six-key block per the reference docs, parse cleanly, and get no behaviour and no diagnostic, while a reader of the docs had no way to tell the block from the keys that work. Bookkeeping: the enum-VALUE half ('preview') puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched by itself — its prescription hangs on the enum's own error map (the HookBodyCapability precedent); the KEY half is tombstoned with retiredKey() on the non-strict KernelContextSchema (both walked-shape copies registered in RETIRED_KEYS_BY_MAJOR[18]); the DEF half (kernel/PreviewModeConfig, with no carrier left) is registered in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: a kernel context is constructed by host code at boot — not a stack collection member, never stored as a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ADR-0049 / ADR-0087.
    • Done when: No host constructs a kernel context with mode: 'preview' or a previewMode block: both now fail tsc at the authoring site and fail the parse with the prescription (pinned in kernel/preview-mode-retirement.test.ts). Concretely, check three places. (1) Host boot code composing a KernelContext: delete mode: 'preview' (mode defaults to production; use development for local demo work) and delete any previewMode block — neither ever changed runtime behaviour, so removing them changes nothing observable. (2) Code importing PreviewModeConfigSchema, PreviewModeConfig or PreviewModeConfigParsed from @objectstack/spec or @objectstack/spec/kernel: every one is TS2305 after upgrade; no working replacement exists to point at, because the vocabulary described nothing real. (3) TypeScript branching on the RuntimeMode type (a mode === 'preview' arm, a switch over modes): the arm is now unreachable and an exhaustiveness check will fail to compile if it stays — that compile error is the enforced channel for TypeScript consumers. Preview deployment ROUTING is untouched: OS_PREVIEW_MODE and OS_PREVIEW_BASE_DOMAINS keep working exactly as documented (deployment routing, never identity).
  • kernel-event-bus-retention-unit-in-key — the two event-bus retention windows whose name carried no unit: EventPersistence.retention (kernel/events/handlers.zod.ts) and EventSourcingConfig.retention (kernel/events/queue.zod.ts) → retentionDays on both — rename each key; both values are unchanged, and so is the 365 default on EventSourcingConfig
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes these two one entry rather than two is the neighbour they share and the one they do not. Both hang off EventBusConfig, so an author configuring a bus met the same bare word twice and had to learn the unit twice; and on EventSourcingConfig the bare retention sits two keys below snapshotRetention, which is a COUNT of snapshots to keep, not a span of time. retention: 365 and snapshotRetention: 10 read as the same kind of number and are not. Suffixing the duration separates the families at the authoring site; snapshotRetention keeps its name, because a count has no unit to carry. Both are retiredKey() tombstones — neither shape is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an EventBusConfig is the event bus construction argument a host builds in code (stack.zod.ts declares no eventBus key and no metadata kind is bound to one), so it is never a stack collection member and never a stored sys_metadata row, and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata, and the disposition the epoch-instant renames on this same kernel took (epoch-instant-keys-renamed). ADR-0087.
    • Done when: Every EventPersistenceSchema.parse(…) / EventSourcingConfigSchema.parse(…) site and every literal handed to an event bus spells retentionDays; authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged in both cases: a bus configured with retentionDays: 90 keeps events for ninety days exactly as retention: 90 did, and a config that omits the key still gets the 365 default on EventSourcingConfig. The positive-integer bound rides along with the renamed key, so a zero or negative window is still refused — the pin covering that in kernel/events.test.ts was moved onto the new spelling rather than dropped.
  • kernel-health-check-and-hot-reload-durations-unit-in-key — the three plugin-lifecycle durations whose unit lived in a source JSDoc only: PluginHealthCheck.interval, PluginHealthCheck.timeout and HotReloadConfig.debounceDelay (kernel/plugin-lifecycle-advanced.zod.ts) → intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged
    • Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. Each key named milliseconds in its JSDoc — "Health check interval in milliseconds", "Timeout for health check in milliseconds", "Debounce delay before reloading (milliseconds)" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree by the gate's own census (check-duration-unit-keys --list): all three read [name: -] [prose: -] — no unit in the name and none in the published prose either. interval is the sharpest of the three: its describe carried one unit-shaped token, the parenthetical "(default: 30s)", which names SECONDS for a value the schema bounds and defaults in MILLISECONDS (min 1000, default 30000). That is the 1000x confusion the rule exists for, published to the one reader who cannot see the source. The suffix is the family's own spelling, counted on this tree: 100 key-position *Ms declarations across packages/spec, timeoutMs 29 of them and intervalMs 3, so both renames land on names the surface already uses. debounceDelay takes the plain suffix rather than a shortened form: it is the only debounce-shaped key spelling in the whole repo (5 key-position occurrences, all of this one key and its fixtures, no debounceMs variant anywhere), while the Delay-plus-Ms pairing is already attested (maxDelayMs, initialDelayMs, retryDelayMs, delayMs) — so unlike the Ttl-versus-TTL question the sibling round had to settle, there is no competing family spelling to choose between. All three old spellings are retiredKey() tombstones: neither PluginHealthCheckSchema nor HotReloadConfigSchema is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and here the stripped value lands on a setInterval period, a race deadline and a setTimeout delay. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — no metadata-type binding, stack collection or manifest embed carries either, and both are library parameters a host passes to PluginHealthMonitor / HotReloadManager in TypeScript (kept twice: as the hot-reload vocabulary that had an implementation when the manifest-side copy was removed, and as a host-driven library when the declarative lifecycle config container was retired) — so a conversion would be a transform with no seam that ever runs. That is the same disposition plugin-auto-restart-never-reinitialised and hot-reload-watch-placeholder-retired recorded for keys on these two defs. The registration-time refusals in PluginHealthMonitor.registerPlugin and HotReloadManager.registerPlugin are the door for the audience that does not parse. Measured on 884e8347d: the only in-repo readers are packages/core/src/health-monitor.ts and packages/core/src/hot-reload.ts, both moved in this same change; and the pinned objectui checkout — the pin this repo builds against, .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6 — names neither def and neither key: all thirteen exports of plugin-lifecycle-advanced.zod.ts and the string debounceDelay each occur 0 times across its 8351 tracked files (0 across the 8281 at 47b1f0bb7, the 8234 at f0268ad78, the 7754 at a58626c88, the 7650 at 0abd4f9f8, the 7632 at 9dfaca654, the 7579 at 2e818d0b5, the 10267 at ab1879721, the 10071 at 89cad75d5, the 9912 at 31971ff1e, the 9800 at e420df310, the 9546 at db11afd49, the 9283 at dd3f7e1be, the 8512 at f8a9d0fb0 and the 8303 at 62597c588 too), against lit controls objectstack 12966 and @objectstack/spec 4997 on the same corpus at 87af769e9, which re-count to 13125 and 5043 respectively at 62597c588, to 13347 and 5123 at f8a9d0fb0, to 13745 and 5466 at dd3f7e1be, to 14704 and 5545 at db11afd49, to 15352 and 6024 at e420df310, to 15691 and 6206 at 31971ff1e, to 16044 and 6461 at 89cad75d5, to 16377 and 6665 at ab1879721, to 17227 and 7134 at 2e818d0b5, to 17313 and 7186 at 9dfaca654, to 17390 and 7209 at 0abd4f9f8, to 17468 and 7246 at a58626c88, to 17956 and 7522 at f0268ad78, to 17980 and 7523 at 47b1f0bb7 and to 18047 and 7545 at this pin (git grep -o -F, the method that reproduces every earlier count).
    • Done when: Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every one of a HotReloadConfig spells debounceDelayMs — concretely packages/core/src/health-monitor.ts, whose loop now reads setInterval(..., config.intervalMs) and whose race reads config.timeoutMs, and packages/core/src/hot-reload.ts, whose debounce now reads config.debounceDelayMs. Authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription naming the suffixed key; handing one to registerPlugin on either class is refused with an ADR-0112 VALIDATION_ERROR / 400 before the plugin is stored. Behaviour is unchanged: the same milliseconds, the same 30000 / 5000 / 1000 defaults and the same min bounds (1000 / 100 / 0), and the published describes now name milliseconds. The sibling shutdownTimeout on HotReloadConfig is deliberately NOT renamed with them: its JSDoc reads "Graceful shutdown timeout" and names no unit anywhere, so it is the unit-nowhere shape (no unit in the name or in the published describe, first measured on two tenant timeouts) that the duration-unit gate leaves outside its verdict, not part of this row set.
  • kernel-package-lifecycle-durations-unit-in-key — the three package and version lifecycle durations whose name carried no unit: UpgradePlan.estimatedDuration (kernel/package-upgrade.zod.ts), PackageDependencyResolutionResult.resolvedIn (kernel/plugin-security.zod.ts) and MultiVersionSupport.rollout.duration (kernel/plugin-versioning.zod.ts) → estimatedDurationSeconds, resolvedInMs and durationMs — rename each key; every value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one story told to one audience — a package being planned, resolved and rolled out — and because the group is precisely where the unit SPLITS: estimatedDuration is SECONDS while resolvedIn and rollout.duration are MILLISECONDS, three adjacent measurements of the same install, two units, none of them named. A reader who learned the unit from one of these three learned it wrongly for the other two. The rollout case adds a second confusion of its own: duration sat directly beside the unit-less percentage, so one block carried a proportion and a span as indistinguishable bare numbers; percentage keeps its name, because a proportion has no time unit to carry. All three are retiredKey() tombstones; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: an UpgradePlan is GENERATED by IPackageService.planUpgrade() before an upgrade runs, a PackageDependencyResolutionResult is emitted by a resolution run, and MultiVersionSupport is a version-routing argument a host constructs — none is a stack collection member or a stored sys_metadata row, so the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. ADR-0087.
    • Done when: Every IPackageService.planUpgrade() implementation returns estimatedDurationSeconds and every caller reads it under that name; every dependency-resolution producer returns resolvedInMs; every multi-version rollout literal spells durationMs. Authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged in every case, and the unit split is the thing to check by hand rather than by search-and-replace: estimatedDurationSeconds: 120 is two MINUTES, while durationMs: 3600000 is one HOUR — a mechanical rename that moved a value between the two would be a thousand-fold error the parse cannot catch, since both bounds accept any non-negative integer.
  • kernel-plugin-health-report-durations-unit-in-key — the two plugin health-report metrics whose name carried no unit: PluginHealthReport.metrics.uptime and PluginHealthReport.metrics.responseTime (kernel/plugin-lifecycle-advanced.zod.ts) → uptimeMs and responseTimeMs — rename each key; both values are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. uptime is the case this rule was written for, and this repo had already paid for it in documentation: the platform serves a SECONDS-valued uptime on GET /health and stores a MILLISECONDS-valued uptime on this report, so the protocol lifecycle page carried a standing paragraph whose whole job was telling the two apart ("metrics.uptime is in milliseconds, unlike the seconds-valued uptime of GET /health above"). A prose warning that has to exist is the symptom; the key name is where the fix belongs. responseTime moves with it because it is a sibling in the same metrics block and because the identical bare name means HOURS on PluginSecurityManifest.vulnerabilityDisclosure.responseTime, renamed by this same card. The other metrics keep their names, deliberately: memoryUsage is bytes, cpuUsage is a percentage, activeConnections is a count and errorRate is a rate — none is a duration, and this rule reaches durations only. Both are retiredKey() tombstones inside the live metrics block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a health report is EMITTED by the monitor each round (packages/core/src/health-monitor.ts) and kept in memory — never authored into a metadata document, never a stored sys_metadata row — so the conversion chain has no seam that would see one, the same disposition HealthStatus.timestamp took (epoch-instant-keys-renamed). ADR-0087.
    • Done when: Every producer of a PluginHealthReport spells uptimeMs and responseTimeMs — concretely packages/core/src/health-monitor.ts, the one production writer, whose metrics block now reads uptimeMs: Date.now() - startTime. Every consumer reading result.metrics?.uptime moves to result.metrics?.uptimeMs. Authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: the value is still Date.now() - startTime in milliseconds, and a report that omits metrics entirely is still valid. ⚠️ Two identically-spelled keys NEARBY are not part of this and must not be renamed with it: the seconds-valued uptime of the GET /health response body, and the free-form HealthStatus.details record, which is a z.record whose contents this rule does not reach.
  • kernel-plugin-security-durations-unit-in-key — the four plugin-security durations whose name carried no unit: SandboxConfig.process.timeout, KernelSecurityPolicy.authentication.tokenExpiration, KernelSecurityPolicy.auditLog.retention and PluginSecurityManifest.vulnerabilityDisclosure.responseTime (kernel/plugin-security-advanced.zod.ts) → timeoutMs, tokenExpirationSeconds, retentionDays and responseTimeHours — rename each key; every value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These four are one entry because they are one document — everything here hangs off a PluginSecurityManifest — and because together they are this rule's clearest case in the whole spec: FOUR durations on one manifest carried FOUR DIFFERENT units (milliseconds, seconds, days, hours) and not one of them said so in its name. The sharpest pair is responseTime. On this manifest it means HOURS (how fast a publisher promises to answer a vulnerability report); on PluginHealthReport.metrics, renamed by the same card, the identical bare name meant MILLISECONDS. So responseTime: 24 was a day on one kernel shape and a fortieth of a second on another, with nothing at the authoring site to tell them apart. The policy was already inconsistent with itself, too: its rate-limit window two blocks above tokenExpiration was ALREADY spelled windowMs, so one security policy carried both conventions. All four are retiredKey() tombstones inside live blocks whose siblings must keep parsing; no shape here is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: a PluginSecurityManifest is a package artifact a publisher ships and a SandboxConfig is the isolation argument a host constructs, so neither is a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one. That is what ruling B prescribes for a key that is not authorable metadata. One key deliberately left alone: RuntimeConfig.resourceLimits.timeout on this same file names its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel the gate does not read: it reads .describe() and .meta({ description }), and that key's describe ("Maximum execution time") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename; that JSDoc-channel gap was filed as a finding of its own and is closed for this key by kernel-runtime-config-timeout-unit-in-key. ADR-0087.
    • Done when: Every SandboxConfigSchema.parse(…), KernelSecurityPolicySchema.parse(…) and PluginSecurityManifestSchema.parse(…) site, and every literal handed to a plugin sandbox or security manifest, spells the suffixed keys; authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged in every case: a sandbox given timeoutMs: 30000 kills a spawned process after thirty seconds exactly as timeout: 30000 did, a policy with tokenExpirationSeconds: 3600 still expires tokens hourly, retentionDays: 90 still keeps ninety days of audit log, and responseTimeHours: 24 still promises a twenty-four-hour disclosure response. Every integer bound rides along with its renamed key. Verify the sharp pair explicitly: a manifest and a health report in the same codebase must now read responseTimeHours and responseTimeMs respectively, and neither accepts the bare name.
  • kernel-runtime-config-timeout-unit-in-key — RuntimeConfig resourceLimits.timeout (kernel/plugin-security-advanced.zod.ts) → resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged
    • Why not automatic: This entry COMPLETES what the kernel-directory duration renames deliberately left alone, and the two are meant to be read as a sequence. That round renamed the four plugin-security durations on this same file (kernel-plugin-security-durations-unit-in-key) and recorded, accurately, that one key was out of its scope: RuntimeConfig.resourceLimits.timeout named its unit only in the JSDoc above it ("Execution timeout in milliseconds"), a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and that key's describe ("Maximum execution time") named none, so the gate listed it among the duration-shaped keys without judging it, neither an offender nor an exemption. That JSDoc-channel gap was filed as a finding of its own, and that round's statement about its own scope stays true. The finding is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. So the reader who most needs the unit — the reader of the published reference page, who never sees the source JSDoc — got a bare integer on content/docs/references/kernel/plugin-security-advanced.mdx and could not tell 60000 milliseconds from 60000 seconds. The key is renamed and the describe is corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation (unit in prose, none in the name). Spelled Ms, the same token SandboxConfig.process.timeoutMs on this very file already carries: counted on this tree, the suffixed family spells it that way in every member (29 key-position timeoutMs declarations across packages/spec/src/**/*.zod.ts, 40 distinct *Ms keys) and there is no timeoutMillis, timeout_ms or timeoutMS variant anywhere in packages/spec/src. Tombstoned with retiredKey() because the nested resourceLimits object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: a RuntimeConfig is the engine block of the SandboxConfig a host or a plugin security manifest constructs — stack.zod.ts declares no sandbox, security-policy or runtime-config collection and it is not a stored sys_metadata row — so the conversion chain has no seam that runs on it; the same reading the kernel-directory round recorded for the four keys it renamed. Measured on 146c291943: no in-repo runtime reads the key — packages/core/src/security/sandbox-runtime.ts, the one consumer of this shape, reads resourceLimits.maxCpu (3 occurrences of resourceLimits) and spells timeout 0 times; outside the zod file and its test the only live occurrences are the generated rows in content/docs/references/kernel/plugin-security-advanced.mdx, which this rename regenerates. The pinned objectui checkout — this is the pin we build against, .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6, re-read from this tree — spells resourceLimits.timeout 0 times across 8351 tracked files, against lit controls timeout 1694, RuntimeConfig 337 and resourceLimits 2 on the same corpus (0 across 8281, and 1674 / 337 / 2, at 47b1f0bb7; 0 across 8234, and 1658 / 337 / 2, at f0268ad78; 0 across 7754, and 1431 / 299 / 2, at a58626c88; 0 across 7650, and 1360 / 293 / 2, at 0abd4f9f8; 0 across 7632, and 1360 / 293 / 2, at 9dfaca654; 0 across 7579, and 1351 / 276 / 2, at 2e818d0b5; 0 across 10267, and 1348 / 273 / 2, at ab1879721; 0 across 10071, and 1331 / 273 / 2, at 89cad75d5; 0 across 9912, and 1303 / 273 / 2, at 31971ff1e; 0 across 9800, and 1293 / 273 / 2, at e420df310; 0 across 9546, and 1197 / 273 / 2, at db11afd49; 0 across 9283, and 1172 / 263 / 2, at dd3f7e1be; 0 across 8512, and 1096 / 245 / 2, at f8a9d0fb0; 0 across 8303, and 1086 / 240 / 2, at 62597c588); both resourceLimits hits are prose in packages/app-shell recording that objectui's own AppShellRuntimeConfig shares not one key with the spec's RuntimeConfig, so nothing there authors this key and no pin bump is owed. ADR-0087.
    • Done when: Every RuntimeConfigSchema.parse(…) site, and every literal handed to a plugin sandbox as its runtime block, spells resourceLimits.timeoutMs; authoring resourceLimits.timeout fails to compile (input type never) and fails to parse with the rename prescription naming timeoutMs and the shape it belongs to. Behaviour is unchanged: a runtime given timeoutMs: 60000 aborts execution after sixty seconds exactly as timeout: 60000 did, and the min(0) integer bound rides along with the renamed key. The published describe reads "Maximum execution time in milliseconds". Verify the two same-named keys on this one file apart: RuntimeConfig.resourceLimits.timeout and SandboxConfig.process.timeout both retire to a key spelled timeoutMs, and each refusal names its own shape so an upgrading author edits the right block.
  • kernel-startup-orchestrator-durations-unit-in-key — the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts) → timeoutMs, durationMs and totalDurationMs — rename each key; every value is unchanged, and so is the 30000 default on StartupOptions
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These three are one entry because they are one boundary: a host passes StartupOptions in, and the orchestrator hands PluginStartupResult and StartupOrchestrationResult back from the same call. The file already contained its own counter-example — IStartupOrchestrator.startWithTimeout(plugin, context, timeoutMs) named its parameter timeoutMs while the options object beside it said timeout, so one contract carried both conventions and the suffixed one was already the honest half. totalDuration is the sum of the per-plugin durations, so the two had to move together or the aggregate would have been spelled unlike its parts. All three are retiredKey() tombstones; none of these shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: StartupOptions is a boot-time call argument and the two result shapes are emitted measurements, so none is ever a stack collection member or a stored sys_metadata row and the conversion chain has no seam that would see one — the same disposition HealthStatus.timestamp took on this very file (epoch-instant-keys-renamed), and what ruling B prescribes for a runtime-emitted key. ADR-0087.
    • Done when: Host boot code calling orchestrateStartup(plugins, options) spells timeoutMs; every implementation that BUILDS a PluginStartupResult spells durationMs and every one that builds a StartupOrchestrationResult spells totalDurationMs. Authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged in every case: an orchestrator given timeoutMs: 5000 waits five seconds per plugin exactly as timeout: 5000 did, an omitted key still defaults to 30000, and the non-negative bounds ride along with the renamed keys so a negative timeout or a negative duration is still refused. One thing this rename deliberately does NOT touch: packages/core/src/plugin-loader.ts declares its own local PluginStartupResult interface — a different type, carrying startTime rather than any duration key — which is not a reader of this schema and is unchanged.
  • list-view-navigation-view-retired — view.list.navigation.view → page assignment — assign a record page to the object and let isDefault pick the one that opens. That is the machinery that resolves a detail layout by name; a list view's navigation block decides only HOW the detail is surfaced (mode, size, preventNavigation, openNewTab, width), and every one of those keys is unchanged
    • Why not automatic: DECLARED, CONSUMED, AND WRONG — which is why this is a semantic TODO rather than a mechanical strip. The key's describe promised "the form view to use for details" and no layer from spec to console ever resolved a view by name. Its only read in the shipped console put the value in the SECOND argument of onNavigate, the slot that otherwise carries the navigation-MODE token: an authored view did not select a view, it SUBSTITUTED for the mode. At least one consumer in the same bundle reads that argument against a closed two-value vocabulary (edit / view), so any other authored value matched neither branch — invisible on grids whose handler takes one argument, a dead row click on the ones that do not. The enumeration behind the removal was exhaustive rather than sampled: every .view property read in the bundle (three) and every formViews read, and NO read anywhere is keyed by an authored view name, so there is no path by which this key or any sibling could have resolved one. ADR-0049 enforce-or-remove; zero authored instances in this repo and the one external author removed its occurrence, so the pull that would justify ENFORCE is zero. A mechanical D2 strip was weighed and declined with the direction: deleting the key silently discards the author's actual intent — "open the detail in THIS layout" — and leaves no record of which list view carried it, which is exactly the judgement a semantic TODO exists to hand back. Should "open the detail in a chosen view" ever be pulled, it belongs to the page-assignment machinery (record pages, isDefault), not to a string on a list view.
    • Done when: For EACH list view that declared navigation.view — the TODO names the surface, you name the view: delete the key from that view's navigation block, then decide whether the detail layout it asked for was ever actually delivered. It was not, so nothing regresses by deleting it: confirm the record detail opens exactly as it did before (the surviving mode and size decide that, and both are untouched). If the named layout is one you still want, publish it as a record page on that object and mark the one that should open isDefault. Done when no navigation block in your sources carries view; a block that still does fails to parse with the removal prescription, at ListViewSchema, at ObjectListViewSchema and at the PUT /api/v1/meta/view overlay door, and authoring it is a tsc error at the call site. ⚠️ Nothing else in the block moves — a navigation that carries only live keys ({ mode: 'drawer', size: 'lg' }) parses byte-for-byte as it did before.
  • list-view-page-mount-retired — view.list / view.listViews.* — the list-view type page and its pageName binding → Publish the page and give the app a navigation item for it — { type: 'page', pageName: '<page_name>' } under the app navigation, the page mount that has always rendered. Keep the list view only if it should draw rows of its object, as a grid or one of its siblings.
    • Why not automatic: The D2 conversion view-page-mount-removed deletes type: 'page' (the schema default then parses the view as grid) and pageName from every view payload in stack.views[], in all three persisted spellings, and the delete is lossless in pixels: no renderer ever routed the page member, so a page view has always drawn an empty grid, and it still does. The author wanted a PAGE in front of users at that place in the app, and the view never showed it. Whether to reach the page through a navigation item, and whether the now-plain grid view should exist at all, are the author's decisions. One boundary is theirs by construction: a page mount declared under objects[].listViews is reached by no conversion, so it is refused at its own door until edited by hand.
    • Done when: No list view in stack.views[] or in any object listViews map declares type: 'page' or pageName; the parse refuses both by name. For each view that did: the page it named is reachable from the app navigation and renders when opened, and the list view either draws rows of its object or has been deleted. No navigation entry points at a view that now renders an empty grid by accident.
  • list-view-sort-string-clause-retired — view.list.sort / view.listViews.*.sort — the bare string sort clause → The structured array, sort: [{ field, order }], with order written out — a bare field name meant ascending — and one entry per key of a comma-separated clause, in the same order.
    • Why not automatic: The D2 conversion list-view-sort-string-clause-to-array rewrites a string clause in the grammar the wire normalizer splits on — 'created_at desc', a bare field name, a comma-separated list — into the array, losslessly, across every view payload in stack.views[]. Two cases are deliberately left for the author. A string that does NOT parse as that grammar — above all the leading-minus dialect, '-created_at' — is left alone and refused at the door, because guessing a direction would invent an ordering the author never wrote. And a clause under objects[].listViews is reached by no conversion, so it is refused at its own door until rewritten by hand. The clause was minted by the schema and refused by the renderer that lowers it into a query, so a view carrying one may already have been failing to load; which order the author meant is theirs to state.
    • Done when: No list view in stack.views[] or in any object listViews map carries a string sort; the parse refuses one with the rewrite prescription. Every rewritten array names fields that exist on the view's object with the direction the author intends, and the view loads — rather than failing at the renderer — with its rows in that order.
  • list-view-tabs-retired — view.list.tabs / view.listViews.*.tabs — the list view's own tab definitions → One named list view per tab, under the object's listViews — the saved-view switcher above the object's records renders every entry as a tab. The tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry, beside the columns the tab should show. A tab whose view already named a list view needs nothing more.
    • Why not automatic: The D2 conversion view-list-tabs-removed deletes tabs from every list payload in stack.views[], in all three persisted spellings, and the delete is lossless in pixels: no renderer ever mounted a tab bar for the key, so a view that declared tabs has always drawn without them, and it still does. The judgment the conversion cannot make is the author's intent: each tab was a named preset the author wanted end users to switch to, and the platform delivers that as a named list view, not as a sub-key of one. Which tabs deserve an entry, what each should filter and show, and whether the switcher already lists an equivalent, are the author's decisions. The tab keys with no list-view counterpart — icon, order, pinned, isDefault, visible — never had an effect either. One boundary is the author's by construction: tabs declared under objects[].listViews are reached by no conversion, so such an object is refused at its own door until edited by hand.
    • Done when: No list view in stack.views[] or in any object listViews map declares tabs; the parse refuses the key by name at every list-view door. For each view that did: every tab the author still wants is a listViews entry with its own label, filter and columns, and it appears as a tab in the switcher above the object's records and shows the rows its filter selects; a tab nobody wants is simply gone. No page-level userFilters preset bar changes — that tabs is a different key, and it stays.
  • logging-durations-unit-in-key — HttpDestinationConfig batch.flushInterval/retry.initialDelay/timeoutand LoggingConfigbuffer.flushInterval (system/logging.zod.ts) → batch.flushIntervalMs (default 5000) / retry.initialDelayMs (default 1000) / timeoutMs (default 30000) on HttpDestinationConfig, and buffer.flushIntervalMs (default 1000) on LoggingConfig — rename the keys; every value (milliseconds) is unchanged
    • Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. All four keys named milliseconds in a source JSDoc — "Flush interval in milliseconds", "Initial retry delay in milliseconds", "Timeout in milliseconds" — and the JSDoc above a key is not what content/docs/references/** renders; .describe() is, and none of the four carried one at all. Measured by the check:duration-unit-keys census on this tree before the change, all four read [name: -] [prose: -]: no unit in the key and no published prose to supply it, so content/docs/references/system/logging.mdx printed a bare 5000 / 1000 / 30000 / 1000 and nothing on the page decided milliseconds from seconds. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so each key is renamed and given the describe it never had in the same stroke. ⚠️ flushInterval was declared TWICE on this file, in two different defs and with two different defaults — 5000 on the HTTP destination's batch and 1000 on the logging buffer — so they are two keys, each with its own tombstone and its own registered row; the prescriptions name their def so a reader who lands on one is not sent to the other. The Ms suffix is the family's own spelling, counted in key position on this tree: 272 *Ms: declarations in packages/spec/src against 75 *Seconds:, and the only competing unit spellings are 3 *MS: and 9 *Millis: — every one of them a name fixed outside this repo (MongoDB's maxCommitTimeMS and connectTimeoutMS, node-postgres's idleTimeoutMillis and connectionTimeoutMillis on PoolConfigSchema), so unlike the Ttl-versus-TTL question a sibling round settled there is no in-repo alternative to choose between. All three target spellings were already attested as key-position *.zod.ts declarations before this change: flushIntervalMs 1 (kernel/events/integrations.zod.ts, same 1000 default), initialDelayMs 5, timeoutMs 30. Tombstoned with retiredKey() rather than deleted because none of the four enclosing objects — HttpDestinationConfig itself and its nested batch and retry, and LoggingConfig's nested buffer — is .strict(), so a bare deletion would have stripped the value in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no logging collection and neither LoggingConfigSchema nor HttpDestinationConfigSchema is referenced anywhere in packages/spec/src outside system/logging.zod.ts, so the chain has no rehydration seam that runs on an authored logging document — the same reading tenant-schema-cache-ttl-unit-in-key recorded for its sibling key. Measured on 4dab2bc5c: no in-repo runtime reads any of the four — outside packages/spec/src/system/logging.zod.ts and its test the only occurrences are the generated rows in content/docs/references/system/logging.mdx, which this rename regenerates; and the pinned objectui checkout — .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6 — spells flushInterval 0 times, initialDelay 0, HttpDestinationConfig 0 and LoggingConfig 0 across its 8351 tracked files, against lit controls useState 2630 and timeout 1694 on the same corpus (all four 0 across 8281, against 2630 and 1674, at 47b1f0bb7, 0 across 8234, against 2622 and 1658, at f0268ad78, 0 across 7754, against 2491 and 1431, at a58626c88, 0 across 7650, against 2478 and 1360, at 0abd4f9f8, 0 across 7632, against 2477 and 1360, at 9dfaca654, 0 across 7579, against 2477 and 1351, at 2e818d0b5, 0 across 10267, against 2476 and 1348, at ab1879721, 0 across 10071, against 2470 and 1331, at 89cad75d5, 0 across 9912, against 2469 and 1303, at 31971ff1e, 0 across 9800, against 2464 and 1293, at e420df310, 0 across 9546, against 2449 and 1197, at db11afd49, 0 across 9283, against 2435 and 1172, at dd3f7e1be, 0 across 8512, against 2391 and 1096, at f8a9d0fb0, and 0 across 8303, against 2389 and 1086, at 62597c588).
    • Done when: Every HTTP log destination spells batch.flushIntervalMs, retry.initialDelayMs and timeoutMs, and every logging buffer spells buffer.flushIntervalMs; authoring any of the four retired spellings fails to compile and fails to parse with a rename prescription naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as before; and each published describe names milliseconds.
  • manage-org-presentation-retired — the manage_org_presentation platform capability (its PLATFORM_CAPABILITIES entry in @objectstack/spec security, the ORG_PRESENTATION_AUTHORING_CAPABILITY constant exported by @objectstack/metadata-core) and the arm of metaWriteCapabilityVerdict that admitted its holders to org-scoped writes of the five org-overridable types through the /meta item doors → grant manage_metadata to whoever must author views, dashboards, reports, translations or email templates through Studio or PUT /api/v1/meta/<type>/<name>; such a write now lands environment-wide (organization_id NULL) and is served to every organization of the deployment. There is no organization-bounded authoring capability: delete manage_org_presentation from every permission set's systemPermissions, and delete any import of ORG_PRESENTATION_AUTHORING_CAPABILITY. metaWriteCapabilityVerdict takes { isSystem, systemPermissions, operation }: drop the canonicalType and activeOrganizationId members from the call
    • Why not automatic: ADR-0131 D6 retires the per-organization overlay axis, and the /meta doors stop carrying an organization into a metadata write (the companion entry meta-doors-organization-scope-retired). The capability admitted an organization admin to exactly the writes those doors threaded into the admin's own organization; with no organization threaded, keeping it would have admitted its holders to environment-wide authoring, which is the reach of manage_metadata and a wider one than the capability ever granted. It was granted by no shipped permission set, so a deployment that never granted it by hand observes nothing.
    • Done when: PLATFORM_CAPABILITY_NAMES no longer holds manage_org_presentation, so the authoring lint resolves the name only where a stack itself declares or grants it. A caller holding manage_org_presentation and not manage_metadata is answered 403 on PUT, DELETE, publish and rollback of /api/v1/meta// (FORBIDDEN on the REST doors, PERMISSION_DENIED on the dispatcher), whatever its active organization. A persisted permission set naming the capability still loads and its other grants still apply. The sys_capability row the platform seeded for it earlier is not pruned (the seeder upserts only); it names a capability nothing consults, and an operator may delete it in Setup.
  • manifest-id-reverse-domain-required — manifest.id — ObjectStackManifest.id, i.e. defineStack({ manifest: { id } })and theid:key of a package manifest — and its registry facePackageSchema.manifestId (marketplace/package.zod.ts) → a reverse-domain identifier matching MANIFEST_ID_PATTERN (kernel/manifest.zod.ts): two or more lowercase dot-separated segments of letters, digits and inner hyphens, each opening with a letter or a digit, never a hyphen — com.acme.crm, org.apache.superset. ⛔ Underscores are not admitted, so manifest.namespace is never a legal id and never a legal last segment of one: com.acme.my_app becomes com.acme.my-app. A bare word gains a prefix: blank becomes com.example.blank. The refusal carries the repaired value it has already checked against the pattern, so the prescription is in the error text, not only here.
    • Why not automatic: Two declarations named one identity and drifted. PackageSchema.manifestId — what the registry stores and addresses a package by — has always carried the reverse-domain regex; ManifestSchema.id, the key an author actually writes, was z.string() and accepted anything. So a package scaffolded, validated, built and booted with an id the publish path would refuse, and the author met the rule for the first time at the one moment it was most expensive to meet. The two sites now reference ONE exported constant, which is what makes a future divergence a visible edit rather than a silent one. Why the rule holds for a package nobody publishes: the TSDoc's own words are "unique across the entire ecosystem" — an id names the artifact for the ecosystem it may one day join, so a private app is named under the same rule as a listed one. Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A mechanical rewrite would re-point every install, dependency declaration and stored manifest_id row at a package that, to the registry, is a different one — and the safe choice between "rename the package" and "keep the id and change nothing that depends on it" is not derivable from the metadata.
    • Done when: Every manifest.id you author matches the pattern, and defineStack / objectstack validate report no manifest.id finding. Prove the rename side separately, because the schema cannot: for each id you changed, confirm nothing still addresses the old value — no installed row, no dependencies entry in another package's manifest, and no registry listing. If any does, the correct answer is a deliberate republish under the new id, not an in-place edit.
  • manifest-permissions-string-list-retired — manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin permission block alone → the structured block permissions: { services, hooks, network, fs } — each a list naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches; or no permissions key when the plugin needs none
    • Why not automatic: ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the consented grant set on the environment artifact with the permission enforcer, never the manifest's request, so a list granted, refused and requested nothing at load; the only code that met one was two reports saying it had been skipped. The D2 conversion manifest-permissions-string-list-removed deletes the list from existing sources and stored artifacts, losslessly for every load. What it cannot do is translate: a capability string such as system.user.read names no service, hook, host or path, so whether the plugin needs a grant at all, and which, is the author's judgement. Authoring now refuses a list at parse with that prescription, and TypeScript rejects it
    • Done when: No manifest — the stack's own, any packages[] entry, any objectstack.plugin.json — declares permissions as a list; a list is refused at parse with its prescription, and TypeScript rejects it. Every plugin whose dropped list stood for a real need declares it in the structured block, naming each service, hook, network host and filesystem path it touches, and os plugin build parses the manifest clean. A plugin that needs no grant declares no permissions key.
  • manifest-version-semver-2-0-0 — manifest.version — ObjectStackManifest.version, i.e. the version:key ofdefineStack({ manifest })and of a package manifest — and its three sibling declarationsMetadataPluginManifestSchema.version (kernel/metadata-plugin.zod.ts), PluginRegistryEntrySchema.version (kernel/plugin-registry.zod.ts) and PluginMetadataSchema.version (kernel/plugin-validator.zod.ts), plus the PATCH /api/v1/packages/:iddoor in@objectstack/runtime`` → a SemVer 2.0.0 string matching SEMVER_2_0_0_VERSION_PATTERN (kernel/version-grammar.ts). ⭐ This is a WIDENING for almost every author: prerelease and build suffixes are accepted for the first time, so 2.0.0-beta.1, 17.0.0-rc.5, 1.0.0+20230101 and 1.0.0-rc.1+exp.sha.5114f85 now pass a key that refused all of them, and identifiers may carry either ASCII case. ⛔ The one thing that stops being accepted is a leading zero in the numeric core: 01.1.1 becomes 1.1.1 — or a different version, if the padded form was standing in for one.
    • Why not automatic: One concept — "the version of a package or plugin" — was judged by four different grammars across ten carriers in two repositories, and the strictest of them, this one, refused 2.0.0-beta.1: the exact string a sibling declaration documented as an example of itself. The contradiction was observable between doors on the same resource, not merely between schema files — the build step refused a prerelease the publish door accepted, while the install door parsed nothing at all. The maintainer ruled one canon, and named it after the standard the repository already claimed in this key's own .describe(), in the generated reference docs, in the Studio help text and in two ADRs: SemVer 2.0.0. Why the narrowing is not losslessly convertible: a version is an identity. 01.1.1 and 1.1.1 are the same release to a reader and different strings to every registry row, dependency declaration and installed artifact that stored one of them, and which of the two an author meant is not derivable from the metadata.
    • Done when: Every manifest.version you author is a SemVer 2.0.0 string, and defineStack / objectstack validate / os plugin build report no version finding. The only values that need touching are those with a leading zero in a numeric segment — the in-repo authoring corpus measured ZERO of them, so most consumers have nothing to change. For each one you do change, confirm nothing still addresses the old string: no installed row, no dependencies range in another manifest, no registry listing. Prove the widening separately and cheaply: a prerelease version that used to be refused at build time now builds.
  • mapping-lookup-params-retired — mapping.fieldMapping[].params.object / .fromField / .toField / .autoCreate — the per-entry reference-resolution keys of a lookup mapping → Nothing on the mapping. A lookup entry copies the cell through, and reference resolution runs afterwards off the TARGET field's own metadata: its reference names the object searched, and the cell is matched as a display value (a name, an email or a record id). Records a row points at must exist before the import runs.
    • Why not automatic: The D2 conversion mapping-lookup-params-removed deletes the four keys from every mapping entry's params, and the delete is lossless: the import path never read them, so stripping them changes no imported row. The judgment is about what the author believed. autoCreate read as "create the referenced record when nothing matches", and nothing was ever created — an unresolved cell fails its row with import_reference_not_found, with or without the key. An import pipeline built on that belief has been losing those rows, and now needs the referenced records seeded first. object, fromField and toField read as the target and the matching columns, and were never consulted: where they named something OTHER than the target field's own reference or a column the resolver matches on, the rows were linked by the field's metadata, not by the mapping — and only the author knows which one they meant.
    • Done when: No mapping entry carries the four keys; the parse refuses them. For each mapping that carried them: the target field's reference names the object the author meant the rows to link to, and a dry run of a representative file resolves every reference cell (no import_reference_not_found row) — or the missing referenced records are created by a step that runs before the import, since the import itself never creates them. Row counts and links match the pre-upgrade import of the same file.
  • memory-persistence-auto-save-interval-unit-in-key — datasource.config.persistence.autoSaveInterval on the memory driver — the file and auto persistence arms → autoSaveIntervalMs — the same interval, in milliseconds, on both arms; the minimum of 100 and the file arm's 2000 default are unchanged.
    • Why not automatic: The D2 conversion memory-persistence-auto-save-interval-to-ms renames the key on both persistence arms of every memory-driver datasource and on stored datasource rows, keeping the value, and leaves a string persistence mode, a custom adapter and every other driver's config alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Nothing in the old name said so, and the auto arm's description named no unit at all. A seconds value below 100 was already refused by the bound, but one above it was not — autoSaveInterval: 300 meant as five minutes saved every 300 milliseconds, and the rename keeps 300. The interval also bounds how much in-memory data a crash can lose, so the author is choosing a durability trade-off, not only a number.
    • Done when: No memory-driver datasource carries autoSaveInterval on either arm; the parse refuses it with the rename. Every autoSaveIntervalMs value is the interval the author intends in milliseconds — a store meant to save every five seconds reads autoSaveIntervalMs: 5000. With file persistence on, a write followed by waiting longer than that interval leaves the change in the persisted file, and the author accepts losing at most that interval of writes on a crash.
  • memory-persistence-placeholder-refused — memory driver config persistence.path(file persistence and theautooverride) andpersistence.key(localStorage and theautooverride) — values containing${…} placeholder syntax → the literal path or key. For environment-specific destinations, leave the key unset and let the shared datasource factory scope the default per datasource, or compute the config value in code before it enters defineStack
    • Why not automatic: The unresolved-placeholder defect one surface over from the datasource connection keys, where it is already refused: a ${…} placeholder in memory persistence config is resolved by NOTHING — the driver would create and write a literal ./${DATA_DIR}/… path, or write under the literal placeholder-bearing localStorage key, so the dump lands in a wrongly-named location with no error naming the unresolved placeholder (authored under the same false belief the 2026-08-13 ruling closes: placeholder syntax in connection-material keys is refused at publish, because nothing resolves it). These two keys are config-material like the connection keys, so the parent adjudication applies with its reason intact; the memory driver's initialData stays deliberately UNJUDGED — it carries arbitrary record values, where a literal ${…} may be legitimate data. 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.
    • Done when: Every memory datasource parses with no ${…} span in persistence.path or persistence.key; initialData record values containing literal ${…} keep parsing byte-identically.
  • meta-doors-organization-scope-retired — the organization the /meta doors of @objectstack/rest and of the runtime dispatcher thread into a metadata write (PUT, DELETE, publish, rollback) or read (item, list, layered, published, drafts, history, audit, diff, diagnostics, references) of the five org-overridable types; the organizationIdForMetaWrite export of @objectstack/metadata-core; the metaReadOrganizationId export of @objectstack/rest; and the Default Organization read of the email-template boot sweep in @objectstack/plugin-email → nothing to write: every /meta write now lands environment-wide (organization_id NULL) and every /meta read resolves environment → code, for every caller and every tenancy posture. Delete any import of organizationIdForMetaWrite (a write carries no organization) or metaReadOrganizationId (a read carries none either). A caller that needs the vetted organization of a request for another purpose still reads metaCallerOrganizationId
    • Why not automatic: ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by a template install belongs to the whole deployment. The doors threaded the active organization for view, dashboard, report, translation and email_template, so under the single posture, where the Default Organization is active, every Studio save of those types was stored under that organization. The read and the write flip together: reads-first would hide the organization rows the doors still wrote, writes-first would let those rows shadow new environment saves.
    • Done when: A manage_metadata caller with an active organization saves a view through PUT /api/v1/meta/view/ on either transport: the stored row carries organization_id NULL and GET serves it. An organization-scoped row stored before this release, including a single-posture Studio save filed under the Default Organization, is no longer served by any /meta read (the environment row or the code definition is), nor projected by the email-template boot sweep; it stays in sys_metadata untouched until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Re-save such an item in Studio to make the edit live on the /meta doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form view in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it.
  • metadata-changed-event-payload-retired — kernel.cluster metadata change event payload (MetadataChangedEventPayloadSchemain kernel/cluster.zod.ts — 2 defs, 4 exported names:MetadataChangedEventPayloadSchema, MetadataChangedEventPayload, MetadataChangeOperationSchema, MetadataChangeOperation) → Nothing to migrate to, because nothing ever emitted or consumed it. The cluster invalidation channels that actually run are the three lanes documented in content/docs/kernel/cluster.mdx §6.2: metadata.changed (ClusterMetadataChangedPayload in @objectstack/metadata — the origin node, the metadata type and the replayed watch event), metadata.mutated (ClusterMetadataMutationPayload in @objectstack/metadata-protocol) and datasource.mutated (ClusterDatasourceMutationPayload in @objectstack/service-datasource). A host that needs cross-node cache invalidation subscribes to one of those; a host that held the retired type for a transport of its own keeps a local type — the spec no longer declares one.
    • Why not automatic: ADR-0049 enforce-or-remove (triage ruling 2026-09-02 on the spec seat: remove via the ADR-0087 route, not "make a consumer" — that is contract growth with no pull). The docblock declared that all metadata persistence layers MUST emit a metadata:changed event with this payload and that every reader MUST subscribe and compare version before invalidating. Measured at the retirement's base commit with positive controls: zero runtime producers, zero subscribers, zero imports outside packages/spec (its own unit test, the isomorphic alias pin and the generated artifacts) in objectstack, and nothing in objectui at the pinned sha. It was unenforceable by construction — the version field is z.bigint(), which the standard JSON serializer refuses, so the payload as declared could not cross any pubsub transport without a codec no driver ships: a MUST-emit contract no conforming emitter could satisfy. The shipped channels all carry an address-only signal whose receiver re-reads its own store (the 2026-09-01 ruling for the registry lane), the opposite of the declared version-compare receipt, so the one plausible future consumer was decided against; the 2026-08-27 ruling on transitions removes a staged window. MetadataChangeOperationSchema existed only to type the payload's operation field and leaves with it as its orphan value schema (the DistributedStateConfig precedent). Route 3: not an authorable surface — no metadata-type binding, stack collection or manifest embed ever carried it, and nothing parsed it outside its own unit test — so no tombstone and no D2 conversion; RETIRED_DEFS_BY_MAJOR[18] (kernel/MetadataChangedEventPayload, kernel/MetadataChangeOperation) plus this entry ARE the declaration.
    • Done when: No code imports MetadataChangedEventPayloadSchema, MetadataChangedEventPayload, MetadataChangeOperationSchema or MetadataChangeOperation from @objectstack/spec or @objectstack/spec/kernel — every one is TS2305 after upgrade (pinned by runtime namespace probes in kernel/cluster.test.ts, with ClusterCapabilityConfigSchema as the positive control). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door. ⚠️ Runtime behaviour is deliberately UNCHANGED: no emitter or subscriber ever existed, and the three shipped cluster lanes publish the same bytes before and after — the retirement removes a false declaration, not behaviour.
  • metadata-customization-protocol-retired — the paper metadata-customization protocol: kernel/metadata-customization.zod.ts whole (MetadataOverlay, FieldChange, CustomizationOrigin, MergeConflict, MergeStrategyConfig, MergeResult, CustomizationPolicy) / the section-5 Overlay/Customization API contracts (api/MetadataOverlayResponse, api/MetadataOverlaySaveRequest, api/MetadataEffectiveResponse) / the optional getOverlay/saveOverlay/removeOverlay/getEffectivemembers ofcontracts/metadata-service.ts/ the authorable keysMetadataPluginConfig.customizationPolicies, MetadataPluginConfig.mergeStrategyandMetadataManagerConfig.persistence.overlayWritable(tombstoned; seeRETIRED_KEYS_BY_MAJOR[18]) → nothing to re-declare — delete any authored keys. The customization mechanisms that actually ship: ADR-0005's org-scoped overlay (opt-in via allowOrgOverride on DEFAULT_METADATA_TYPE_REGISTRY, stored as sys_metadata org rows, written through the REST meta write doors and read back through getMetaItemLayered's code/overlay/effective layers), and ADR-0126's packaged-metadata customization model (clone with a new machine name + ledger disable — never a field-level patch overlay)
    • Why not automatic: ADR-0049 enforce-or-remove; the maintainer's ruling of 2026-08-29 adopted retirement and rejected a re-scope, and it was executed widened to the full coupling set the fork report on that ruling measured: the module declared a three-layer platform/user patch-overlay protocol with field-level change tracking and a 3-way-merge story, published reference docs described it as the customization architecture — and nothing reachable implemented it. The one implementation (packages/metadata's manager limb) was served by no route and called only by its own unit tests; no merge engine ever existed; no code read a CustomizationPolicy. ADR-0126 §6 wall 4 supersedes the protocol as a matter of record ("nothing may build against it") — the per-field overlay layer it described is precisely what the 2026-08-24 lock-and-clone ruling (lock the packaged base, customize a clone) left deliberately unchartered. Why D3 semantic and not a D2 conversion: the defs leave with no carrier key in any stack collection, and the three tombstoned keys live on plugin/manager configs, which are not stack collection members (PLURAL_TO_SINGULAR has no plugins entry) — a MetadataConversion would be a transform with no seam that ever runs (the kernel/Manifest:loading precedent).
    • Done when: No import of metadata-customization.zod (or of the retired names from @objectstack/spec/kernel / @objectstack/spec/api) compiles anywhere; no MetadataPluginConfig carries customizationPolicies or mergeStrategy; no MetadataManagerConfig carries persistence.overlayWritable (TypeScript authors get the refusal at compile time — the keys are typed never — and a value reaching the parse is refused with the prescription at the key's path). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: no route ever served the paper …/overlay / …/effective endpoints, so removing the limb removes no served behaviour — the ADR-0005 org-overlay read/write path (getMetaItemLayered, the REST meta write doors) stays exactly as it was, before and after.
  • metadata-endpoints-switch-radius-repartitioned — restServer.metadata.endpoints.items / restServer.metadata.endpoints.item → An endpoints.* switch now gates exactly the face its name states, reads and writes alike. items gates GET {prefix}/:type and nothing else; the whole-store operations it used to take with it — GET {prefix}/diagnostics, GET {prefix}/_drafts and the POST {prefix}/_migrate-stored write door — answer to the new key endpoints.maintenance (default true). item now gates the WHOLE per-item face: GET / PUT / DELETE {prefix}/:type/:name, /references, /layers, the history family (/history, /audit, /diff, /published, /publish, /rollback) and GET {prefix}/book/:name/tree. ⇒ An embedder that authored endpoints: { items: false } to close the whole-store family writes endpoints: { items: false, maintenance: false }. An embedder that authored endpoints: { item: false } to close only the per-item READS has no key that keeps the writes: the per-item face is one face, so leave item on and close the surface at api.enableMetadata, or per object at enable.apiEnabled / enable.apiMethods. types is unchanged and api.enableMetadata remains the master switch above all four.
    • Why not automatic: Not losslessly convertible, and not compiler-carried either — the two channels that would otherwise reach a consumer are both blind here. No key is renamed, removed or retyped: every one is an optional boolean, so { items: false } compiles and parses exactly as before and simply mounts a different route table. A D2 conversion would have to GUESS which of the four routes the author meant to close, and the two readings differ by a write door — rewriting { items: false } to { items: false, maintenance: false } preserves the old mounts but presumes an intent the author never expressed, while leaving it alone re-mounts POST {prefix}/_migrate-stored. That is a judgment, so it is delegated rather than automated. The change itself is the ADR-0049 declared-vs-enforced defect in the direction the liveness ledger structurally cannot look: all three keys were genuinely live, and what had drifted was each one's RADIUS against its own describe() — items gated a migration write door while naming a listing read, and item gated four reads while its own PUT / DELETE and the history family answered to api.enableMetadata alone. The maintainer ruled the two together on 2026-09-06 as one principle: every endpoints.* switch gates exactly the face its name states, and the whole-store family gets a key of its own. Measured population at the time of the move: ZERO — no shipped boot path constructs a RestServerConfig, so only programmatic embedders can have authored these keys at all.
    • Done when: For each RestServerConfig the consumer constructs, new RestServer(...).registerRoutes() followed by getRoutes() yields the route table the consumer intends — specifically: with endpoints.items: false authored, GET {prefix}/diagnostics, GET {prefix}/_drafts and POST {prefix}/_migrate-stored are PRESENT unless endpoints.maintenance: false is also authored; and with endpoints.item: false authored, PUT {prefix}/:type/:name, DELETE {prefix}/:type/:name and the six history routes are ABSENT. A consumer that authored neither key is unaffected and needs no change: all four switches default true and the default route table is byte-identical to before. The reference measurement is packages/rest/src/rest-config-mount-table.pin.test.ts, which asserts each switch's radius as a set difference against the all-true baseline in both directions.
  • metadata-item-name-grammar-enforced — metadata item names (the namehalf of thetype/nameaddressing pair —saveMetaItem/publishMetaItem, PUT /api/v1/meta/:type/:nameand the compound:type/:section/:name fold) → lowercase snake_case segments, optionally dot-qualified — the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* segments joined by single dots (crm_lead, crm_lead.pipeline). A name that spelled a sub-resource with a slash (views/all_leads) is re-authored with a dot qualifier (crm_lead.pipeline — the ViewItemNameSchema convention, now enforced with the qualifier optional) or flattened with an underscore (views_all_leads); containment is expressed by structure, never by a separator inside the identity string.
    • Why not automatic: Maintainer ruling (2026-08-25): metadata item names must not contain / — identity-with-separator is the measured root cause of a defect family (URL arity mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL spelling split in one SDK file). The grammar was entirely unconstrained at the door: the empty string, // and Views/All Leads were all accepted and stored as item names, and a slash in the name bypassed the unrecognised-metadata-type refusal (type=fieldz name=a/b was accepted while type=fieldz name=a was 400). Whether a stored slash-name (out-of-repo deployments only — the in-repo census measured zero) should be renamed, and to what, is a judgment the chain cannot make, so no mechanical conversion ships with the narrowing.
    • Done when: Every write through saveMetaItem / publishMetaItem whose name is lowercase snake_case segments optionally joined by single dots succeeds exactly as before, flat and dotted alike. Any other name — slash, empty, whitespace, uppercase, leading/trailing/double dots — is refused 400 INVALID_REQUEST with the grammar and the dotted prescription in the message, and nothing is persisted. Reads and deleteMetaItem still answer for pre-grammar residue rows, so any stored junk name remains listable and clearable.
  • metadata-manager-config-cache-ttl-unit-in-key — MetadataManagerConfig cache.ttl/cache.databaseLoader.ttl (kernel/metadata-loader.zod.ts) → cache.databaseLoader.ttlMs (milliseconds, default 60000) — rename the nested key; the value is unchanged. The outer cache.ttl has NO replacement: its respelling ttlSeconds was retired before it shipped (see metadata-manager-config-inert-cache-keys-retired) — delete the key; nothing ever read it
    • Why not automatic: Maintainer ruling B of 2026-09-02 on duration-shaped keys (no grandfathered baseline): the unit of a duration-shaped z.number() key lives in the key NAME or in a unit-carrying value, never only in the description. This block was the founding specimen: two keys spelled ttl fourteen lines apart, the outer one in SECONDS (3600) and the nested DatabaseLoader one in MILLISECONDS (60000), each unit named only in .describe(). An author who copied the outer number into the inner block got a 3.6-second cache with no error anywhere — the number was valid, the type was right, the cache was simply cold. Both keys are retiredKey tombstones (the nested objects are not strict; a bare deletion would strip the old key in silence). Why a semantic entry and not a D2 conversion: MetadataManagerConfig is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the kernel/Manifest:loading and metadata-plugin-additional-types-retired precedent). The one in-repo reader, DatabaseLoader (packages/metadata), reads cache.databaseLoader.ttlMs at the same magnitude it read ttl; the outer cache.ttl had no runtime reader (measured on ca46f8f12, and retired on its own under ADR-0049 before this rename shipped — so this entry's outer half is a deletion, not a rename, and the ttlSeconds spelling never reached a published release).
    • Done when: Every new MetadataManager({ cache: … }) / MetadataManagerConfigSchema.parse(…) site spells cache.databaseLoader.ttlMs and no outer TTL at all; authoring either old ttl fails to compile (input type never) and fails to parse with a prescription — the nested one naming ttlMs, the outer one prescribing deletion and naming cache.databaseLoader.ttlMs; a DatabaseLoader configured with ttlMs: 60000 expires entries after 60 seconds exactly as ttl: 60000 did.
  • metadata-manager-config-inert-cache-keys-retired — MetadataManagerConfig cache.enabled/cache.ttlSeconds(formerlycache.ttl) / cache.maxSize(kernel/metadata-loader.zod.ts; tombstoned, seeRETIRED_KEYS_BY_MAJOR[18]) → nothing to re-declare — delete the three outer keys. The cache that actually runs is the DatabaseLoader read-through LRU under cache.databaseLoader: its enabled (default true) is the switch, ttlMs (milliseconds, default 60000) the TTL and maxSize (an entry count, default 500) the cap
    • Why not automatic: ADR-0049 enforce-or-remove (the owning seat's ruling, conditioned on the measurement below and re-taken on the merged ref): the outer cache block of MetadataManagerConfig advertised three knobs — enabled (default true), ttlSeconds (default 3600; ttl until the duration-unit rename) and maxSize ("bytes") — that no runtime read. The only consumer of the block is MetadataManager (packages/metadata), which hands cache.databaseLoader and nothing else to new DatabaseLoader({ cache }); a repo-wide reader census over packages/** (tests and changelogs excluded) found no runtime reader of any outer key, while the same grep shape found the nested cache?.databaseLoader read twice (the positive control). An author writing cache: { enabled: false } or cache: { ttlSeconds: 60 } got a clean parse and a cache that behaved exactly as before, and the published reference page documented all three as if they configured something. All three are retiredKey tombstones (the nested object is not strict; a bare deletion would strip them in silence — the same no-op one layer down). The duration-unit ruling's ttl → ttlSeconds rename, registered under this same major and never shipped, is folded into the removal: cache.ttl's tombstone now prescribes deletion rather than a rename to a key that is itself retired, so a 17.x author sees one hop. Why a semantic entry and not a D2 conversion: MetadataManagerConfig is the runtime MetadataManager's constructor config, not a stack collection member and never a stored row, so the chain has no seam that ever runs on it (the kernel/MetadataManagerConfig:persistence.overlayWritable precedent). The other candidate — wiring readers for a second cache layer — was not taken: no consumer for one exists, and an implementation for an unmeasured need is the shape ADR-0049 refuses.
    • Done when: No new MetadataManager({ cache: … }) / MetadataManagerConfigSchema.parse(…) site spells cache.enabled, cache.ttlSeconds, cache.ttl or cache.maxSize (TypeScript authors get the refusal at compile time — the keys are typed never — and a value reaching the parse is refused with the prescription at the key's path, naming cache.databaseLoader). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: the DatabaseLoader read-through cache configured under cache.databaseLoader (enabled / maxSize / ttlMs) behaves exactly as before, and a config that never wrote the outer keys parses to the same output minus the two former defaults (enabled: true, ttlSeconds: 3600) that were materialized and never consulted.
  • metadata-plugin-additional-types-retired — metadata plugin config.additionalTypes(onMetadataPluginConfig) → nothing to re-declare — delete the key. There is no declared-kind channel: a kind enters the live metadata-type set as a side effect of registering an ITEM of that kind (SchemaRegistry.registerItem during app/manifest registration, or MetadataManager.register at runtime). Bind the kind's schema with registerMetadataTypeSchema(type, schema) from the plugin's init(ctx) so GET /api/v1/meta serves a real JSON Schema for it
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling of 2026-08-14: remove the key, jointly with refusing unknown types at the /meta boundary by the static registry. The key was declared, authorable, on the published authorable surface, and documented on four docs pages as THE way a plugin registers a custom metadata type — and read by NOTHING. The only production writer of the manager's type registry is setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY) (packages/metadata/src/plugin.ts), called exactly once outside tests, and it REPLACES the array outright; nothing ever merged additionalTypes into it. Measured against the real MetadataManager: declared count == live count (27 == 27), getRegisteredTypes() sorted equals the built-in registry sorted. So an author who followed the published instructions wrote the key, got no error, and nothing happened — the same silence trap as the plugin lifecycle's onInstall (a documented hook with no invocation site), one level down, in exactly the AI-authoring path (ADR-0033). The joint consequence: with this plugin-declared channel removed, the static registry is the total universe of legal metadata kinds, which makes refuse-by-static-registry at the /meta boundary safe by construction. 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 metadata-plugin config is neither — PLURAL_TO_SINGULAR has no plugins entry, so it is not a stack collection member and a conversion would be a transform with no seam that ever runs (the kernel/Manifest:loading precedent).
    • Done when: No MetadataPluginConfig — inline in TypeScript or embedded at the manifest's config key — carries additionalTypes. TypeScript authors get the refusal at compile time (additionalTypes is typed never); a value reaching the parse is refused with the prescription (invalid_type at path additionalTypes). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the key, so removing it removes no behaviour — the live type set stays exactly DEFAULT_METADATA_TYPE_REGISTRY plus item-population growth, before and after.
  • metadata-write-organization-scope-refused — the organizationId member of the SaveMetaItem, PublishMetaItem and DeleteMetaItem request schemas of @objectstack/spec; and every organization-scoped write the metadata protocol of @objectstack/metadata-protocol accepted: saveMetaItem (draft and publish), publishMetaItem, deleteMetaItem, rollbackMetaItem, revertCommit, rollbackToPackageCommit, publishPackageDrafts, discardPackageDrafts, revertStoredPackage, duplicatePackage and reassignOrphanedMetadata — including the five types that declared allowOrgOverride (view, dashboard, report, translation, email_template) and the OS_METADATA_WRITABLE hatch; and the active organization of the caller publishing a package, which a published seed draft whose records named no organization was loaded into under the group posture → drop organizationId from the request: every metadata write lands environment-wide (organization_id NULL), where every organization reads it. The key is stripped at a spec parse and refused at the protocol: a request that still names an organization is refused with 403 NOT_OVERRIDABLE, before anything is read or written, and the message names the tenancy posture in force. A seed draft sets organization_id on each record (ADR-0131 D12, item 12); under group a seed record that names none is refused at load
    • Why not automatic: ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio, by the cloud build agent or by an install belongs to the whole deployment. The /meta doors already carry no organization (meta-doors-organization-scope-retired); this is the protocol refusing the same write from every other door — the /packages verbs, the stored-row migrations, a plugin — so no path is left that stamps an organization on a metadata row. The audit and commit ledgers are environment-level too (ADR-0131 D7) and record no organization. Legacy organization-scoped rows are not touched: the stored-metadata migration reports them as skipped, the flow credential move reports them as not moved, and the promotion ceremony (ADR-0131 C7) carries them to the environment layer.
    • Done when: A protocol write naming an organization — a saveMetaItem of a view with organizationId set, a publishPackageDrafts or a revertCommit with one — answers 403 NOT_OVERRIDABLE and writes nothing, for every metadata type and every tenancy posture; the same call without the key succeeds and stores organization_id NULL. POST /meta/_migrate-stored reports each organization-scoped row as skipped, naming the promotion ceremony, and re-saves none. A commit recorded in a legacy organization layer is refused by revertCommit with the same code, and duplicatePackage and reassignOrphanedMetadata copy or adopt the environment rows only. Remove organizationId from any typed SaveMetaItem / PublishMetaItem / DeleteMetaItem request literal: the key no longer type-checks.
  • migrations-entry-split — The ADR-0087 migration chain and change manifest, imported from the package root @objectstack/spec: MIGRATIONS_BY_MAJOR, MIGRATION_MAJORS, MIGRATION_SUPPORT_FLOOR, RETIRED_KEYS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, applyMetaMigrations, composeMigrationChain, MigrationFloorError, composeSpecChanges, composeReleaseChanges, the seven change-manifest schemas (SpecChangesSchema, SpecConvertedSchema, SpecMigratedSchema, SpecSurfaceAddSchema, SpecSurfaceRemoveSchema, SpecReleaseChangesSchema, SpecReleaseSurfaceSchema), and the types MigrationStep, MigrationApplication, MigrationChainResult, MigrationHopResult, MigrationTodo, SemanticMigration, SpecChanges, SpecConverted, SpecMigrated, SpecSurfaceAdd, SpecSurfaceRemove, SpecReleaseChanges, SpecReleaseSurface, SurfaceDiff, ReleaseSurfaceDiff and PreviousReleaseRegistries → the same names, unchanged, imported from @objectstack/spec/migrations — change the import path and nothing else. The chain, its steps and semantic entries, the retired-key and retired-def tables and the change-manifest schemas are the same objects, and objectstack migrate meta replays the same chain. The ADR-0087 conversion layer stays on the package root: ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR, applyConversions, applyConversionsToFlow, applyConversionsToStoredItem, collectConversionNotices, the three CONVERSION_*_CODE constants and their types still import from @objectstack/spec.
    • Why not automatic: The maintainer ruled that the console first-screen size ceiling is raised now and paid back at the source; this split is that payback. The migration registry is mostly the guidance text objectstack migrate meta prints, and the package root re-exported it. The registry does work when its module loads (the list of majors and each step's rationale are computed then), so no bundler could prove it unused, and all of that text rode in every bundle of the root, whatever the consumer imported: 1,761,987 of the root ESM bundle's 3,766,221 bytes. With the chain on its own subpath the CommonJS root is 2,009,810 bytes instead of 3,780,033, and a browser bundle of the ten names the Studio console imports from the root drops from 700,884 to 301,287 bytes gzipped. The conversion layer does not move: defineStack and normalizeStackInput read it at run time, so moving its names would narrow the root and shrink it by under two kilobytes. The split moves an import path, which is TypeScript source rather than metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the move is recorded here.
    • Done when: No code imports any of these names from the package root @objectstack/spec — each such import is a TS2305 "has no exported member" error after upgrade, and at run time the binding is undefined. The same names import cleanly from @objectstack/spec/migrations. No metadata document, stored row or JSON Schema reference needs editing: the chain, its tables and the schemas did not change, and objectstack migrate meta rewrites the same documents it did before.
  • object-block-sort-item-array — The sortprop ofobject-gridandobject-calendarinComponentPropsMap(the FORM: the accept-anythingz.unknown()at both block doors, vs theSortItemarray[{ field, order }, ...]) → z.array(SortItemSchema) at both doors — the array ElementDataSourceSchema.sort, ListPageSchema.sort and element:record_picker's flat sort shorthand already carry. The legacy OData-ish clause sort: 'created_at desc' becomes sort: [{ field: 'created_at', order: 'desc' }]; a bare field name sort: 'created_at' meant ascending and becomes sort: [{ field: 'created_at', order: 'asc' }] — order is required in SortItemSchema, so it is written out rather than omitted. A comma-separated clause becomes one array entry per key, in the same order. record:related_list is NOT moved by this entry: its string is the 'field' / '-field' dialect read by RelatedList.normalizeSortSpec, which never reaches convertSortToQueryParams, and retiring it was not ruled. object-grid.defaultSort is a different key, retired separately by the ui__ObjectGridProps__defaultSort entry.
    • Why not automatic: One sort spelling platform-wide, the array: the maintainer's ruling of 2026-09-07 (option B) retired the legacy string sort clause, and its consumer half is the objectui change that drops the string arm from convertSortToQueryParams. One item of that ruling is this entry's subject: 「ComponentPropsMap for object-calendar and object-grid constrains the sort value to the array shape (today it accepts anything), so the spec, the registrations and the helper agree; that is a pull-back to the declared contract, ordinary tier」. The z.unknown() at both doors was a read-point record from the change that brought the object-* blocks into ComponentPropsMap (the maintainer's ruling of 2026-08-12), the same vintage as the filter doors the element-data-source-and-object-block-filter-rule-array entry moved, and not an exception to the ruling: measured on @objectstack/spec 17.2.0 an array, a string and a bare NUMBER all returned success: true while bogusProp was refused by name on the same call, so key checking was live and only the VALUE was unheld. Meanwhile objectui's own html tier has published type: 'array' for the grid all along (plugin-grid/src/index.tsx:222) and answered type-mismatch on the string — a spelling @object-ui/core implemented, the docs taught and the validator refused, which is what made this a ruling rather than a mechanical widening. Sequenced measurement-first: at the objectui pin this repo builds against (53ded82b) the string is still lowered — ObjectGrid.tsx:1844-1851 carries an explicit typeof === 'string' arm onto $orderby, and ObjectCalendar.tsx:431 hands schema.sort to convertSortToQueryParams, whose string arm is still present at sort-query.ts:66-70. So this declaration lands AHEAD of the pinned consumer, which the ruling permits explicitly (either order; the registrations already declare the array). The in-repo sweep found ZERO authored sort on either block — the two showcase pages that author object-grid (command-center.page.ts, my-work.page.ts) declare none — with the same grep shape finding 40+ string sort values at OTHER doors (view definitions, ObjectQL query.sort) as the control that the sweep fires; so this entry carries the prescription for authors outside the repo. ⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 conversion: os migrate meta --stored replays D2 conversions only, and the read path does not re-validate stored rows (applyConversionsToStoredItem replays the chain without validating, by its own contract), so a stored page carrying a string sort keeps loading and is still rendered by objectui at the pinned .objectui-sha. What changes is that RE-SAVING it is refused at the sort door, on its next save and not before. ADR-0049, ADR-0087.
    • Done when: ComponentPropsMap['object-grid' | 'object-calendar'].safeParse({ objectName, sort: [{ field: 'created_at', order: 'desc' }] }) succeeds and the parsed sort is that same array, equal value-for-value to ElementDataSourceSchema.parse({ object, sort: <that array> }).sort. The legacy string clause is refused at the sort path on both doors (invalid_type, expected array), and so is a bare number; a misspelled or ABSENT direction is refused at sort.0.order (invalid_value — order is a required enum, so both take one verdict) and a missing field at sort.0.field (invalid_type). An undeclared key is still refused BY NAME on the same call (unrecognized_keys naming it), the control that makes those refusals verdicts rather than a schema reporting nothing. No sort door in ComponentPropsMap accepts a string except record:related_list, which is the one deliberate exception. At runtime each block orders exactly as the array orders — the same $orderby the string lowered to.
  • object-grid-data-view-data-converged — ``object-gridcomponent props —data(the KIND: bare arrayz.array(z.unknown())vs theViewDataSchema provider object) → ViewDataSchema — the provider-discriminated object (provider: 'object' | 'api' | 'value' | 'schema'). Static inline rows move from data: [...] to data: { provider: 'value', items: [...] } — the same rows, wrapped in the one arm that means "hardcoded data array". The other three arms are unchanged ViewDataSchema semantics; staticData (the deprecated bare-array shortcut the renderer still reads) keeps its shape but is not the prescription
    • Why not automatic: Two entries of one contract disagreed on the KIND (contract-vs-contract, found by objectui's declared-arm parity gate): ComponentPropsMap['object-grid'].data said bare array ('Static inline rows — bypasses the object query') while ViewDataSchema — the authority objectui aligned the grid's registry declaration to, pinned by gridDataInputContract.test.ts, and what ObjectGridSchema.data resolves to — is an object discriminated on provider. Measured on @objectstack/spec@17.2.0: { provider: 'value', items: [] } — the pinned-legal form — was REFUSED by the props-map entry (expected array, received object) while the bare array parsed. Whichever authority a value satisfied, the other refused it, and the objectui parity gate had to carry the reasoned exemption object-grid.data:object to look away. The maintainer's ruling of 2026-08-25 (option A) converged the props-map entry onto ViewDataSchema; the bare-array form is the deprecated staticData shortcut that objectui's deprecated-alias carve-out already refuses to publish as authoring surface. The ruled migration check ran with the change: the sweep of generated artifacts, templates and first-party corpora (examples/, skills/, create-objectstack, spec fixtures) found ZERO bare-array data authors, so no rewrite ships — this entry carries the prescription for authors outside the repo.
    • Done when: ComponentPropsMap['object-grid'].safeParse({ data: { provider: 'value', items: [] } }) succeeds (and the other ViewDataSchema arms parse through the same entry); a bare-array data: [...] is refused at the data path. An author carrying data: [...] writes data: { provider: 'value', items: [...] } — same rows, one wrapping object. Downstream (objectui, after a released spec version reaches the pin): the object-grid.data:object exemption entry in registry-inputs-spec-parity.test.ts becomes deletable, which is what closes the objectui finding that the two authorities disagreed.
  • object-grid-default-filters-rule-array — the object-grid page block's defaultFilters property — the legacy base-filter fallback in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed rules alike → the same ViewFilterRule array form its sibling filter takes — [{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes [{ field: "status", operator: "equals", value: "active" }] and several record keys become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the operator into the rule, becoming [{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array [["owner_id", "=", "{current_user_id}"]] becomes [{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value placeholders and date macros unchanged. Legacy operator shorthands are accepted and normalized on parse. Better still, write the rules on filter and delete this key: it is read only when filter is absent, and its own description has prescribed filter all along
    • Why not automatic: The protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. This is the SAME value in the SAME role as filter — the key's own description says it is read only when filter is absent — and the consumer reads it through the SAME lowering sink, so every refusal that sink can give was reachable from a document the protocol had just accepted. filter converged on the rule array with the rest of its family; this key was not named by that ruling and kept the pre-convergence read-point shape, which left the block with one declared door and one undeclared door onto one seam. The parse receipt said nothing about what the grid would then do with the value, and in the objectui version this release pins that depended on the shape: ObjectGrid lowers defaultFilters through toFilterNode whenever filter lowers to nothing, so a record form and an AST tuple array were lowered and applied as declared; a bare string or a number was dropped without a word, so the grid sent no filter and listed its rows unfiltered; and a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the client before any request for the value shapes it judges itself. ⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key outright — the other arm the finding offered — removes an accepted shape and needs its own ruling; the deprecation already stated in the description is unchanged and still says to prefer filter. Metadata AT REST: the record form and the AST tuple array at this key are rewritten to the rule array by the same D2 conversion as its sibling filter, page-component-filter-record-to-rule-array, wherever the mapping is lossless — by os migrate meta --stored, and on every stored-row read until it runs. What it cannot map losslessly is left exactly as stored and keeps rendering as it does today — a combinator, a null value, an operator the rule vocabulary does not spell, or the bare string or number this key also took — and its door refuses such a value only as the component-props gate's advisory finding (os validate, os build, os lint), since a re-save through the metadata API is not refused there: a record form with the message the filter door gives, a worked rewrite computed from the author's own keys and a pointer to this entry's conversion table, and a bare string or number or an AST tuple array with the schema's plain type refusal. ADR-0049 / ADR-0087.
    • Done when: Every object-grid node in your pages either omits defaultFilters or carries a ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is that array raises no issue at the key; a record form is refused AT defaultFilters with the conversion table and a worked rewrite built from the keys that were written, and an AST tuple array is refused one level in, at the first element. What to re-check depends on the shape that was there, as the objectui version this release pins treats it. A record form or an AST tuple array was lowered and applied, so for those the rewrite is a spelling change. A bare string or a number was dropped by that lowering, so the grid has been listing its rows unfiltered — decide which rows it is supposed to show before writing the rule that selects them. A list of malformed rules was refused when the grid loaded. Where both keys are authored, that grid reads defaultFilters only when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is the whole migration; beside filter: [] the grid reads defaultFilters, so move those rules onto filter rather than deleting them.
  • object-grid-default-sort-retired — page.component.object-grid.defaultSort — the legacy single-pair second spelling of the grid sort → sort: [{ field, order }] — the array every read path honours; a single pair is a one-entry array.
    • Why not automatic: The D2 conversion object-grid-default-sort-removed follows the renderer's own precedence: where sort was absent the defaultSort pair WAS the grid's sort, so it moves to sort as a one-entry array; where sort was present the pair was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT orders has always loaded in the sort order while its author may believe defaultSort governed the initial load — the key's name says it should have. The conversion keeps the order users have been seeing and discards the one the author wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches.
    • Done when: No object-grid component carries defaultSort; the parse refuses it. Each grid's sort array lists the fields and directions the author intends, and the grid loads with its rows in that order and shows that column as sorted. For every grid that had authored both keys, the author has compared the discarded defaultSort pair with the kept sort and confirmed the kept one.
  • object-grid-resizable-columns-retired — page.component.object-grid.resizableColumns — the legacy second spelling of the grid column-resize switch → resizable: true | false — the one spelling the grid reads; the value is the same boolean.
    • Why not automatic: The D2 conversion object-grid-resizable-columns-removed follows the renderer's own precedence, resizable ?? resizableColumns: where resizable was absent the legacy value WAS the grid's setting, so it moves to resizable unchanged; where resizable held a value the legacy key was never read, so it is deleted. Both are behaviour-preserving, and the second is where the judgment sits. A grid that authored both keys with DIFFERENT values has always behaved as resizable said, while its author may believe the other key governed it. The conversion keeps what users have been seeing and discards the value the author also wrote; only the author can say which one they meant. Code that builds object-grid props (a host, a generator) must also stop emitting the key, which no conversion reaches.
    • Done when: No object-grid component carries resizableColumns; the parse refuses it. Each grid that should let users drag column borders either omits resizable (the renderer default is on) or sets it to true, and each that should not sets resizable: false. For every grid that had authored both keys, the author has compared the discarded value with the kept resizable and confirmed the kept one.
  • object-index-unknown-keys-refused — object indexes[] entries (IndexSchema) — undeclared keys → the declared surface: name / fields / unique (ADR-0120 scope). A key that names no declared capability is simply removed. where — the console fallback editor's drifted spelling for a partial-index predicate, removed when objectui converged that editor onto IndexSchema — gets a curated prescription: partial indexes are built at the database layer (CREATE [UNIQUE] INDEX … WHERE from a runtime migration), never declared here
    • Why not automatic: The unknown-key strictness campaign held this site open on a measured risk, the kind that had already made a console save answer 422 (a strict schema refusing a key the console itself writes): objectui's embedded index editor shipped a drifted hand-copied schema offering where and brin, spliced its output into object.indexes[] and PUT the whole object, so closing the shape would have 422'd a control the console itself rendered. objectui then converged that editor to the declared surface, spending the hold's evidence. Before this close an undeclared key on an index parsed clean and was silently dropped — an admin filling the old "Partial-index predicate" control got a green save while no driver ever read the predicate (syncDeclaredIndexes consumes name/fields/unique only). Undeclared keys are now refused at parse time with a prescriptive message; the protocol-17 type/partial tombstones keep answering their own migration text.
    • Done when: Every indexes[] entry parses with only name / fields / unique. Declared keys parse byte-identically to before, at every ADR-0120 unique spelling. A stored body from the drift window carrying indexes[].where is rejected with the database-layer prescription rather than saved with the key silently dropped.
  • object-kanban-quick-add-retired — page.component.object-kanban.quickAdd — the per-column quick-add switch on the metadata-driven board → (removed from the metadata board.) Delete the key; object-kanban offers no quick-add control. On a metadata board, records are created through the object's ordinary create action.
    • Why not automatic: The D2 conversion object-kanban-quick-add-removed deletes quickAdd from every object-kanban component, and the delete is lossless: the board forwarded the flag, but the control also needs a host-supplied onQuickAdd function that JSON cannot carry and no producer ever put on an object-kanban node, so the gate was permanently false and no board ever showed the control. The residue is the requirement behind the flag. An author who set quickAdd: true wanted users to add a card inside a column; that never happened and still does not. Whether the board can live without it is a product decision about that board — not something a key delete can make.
    • Done when: No object-kanban component carries quickAdd; the parse refuses it. Each board renders the same columns and cards as before the upgrade. For each board that had set the flag, the author has accepted creating records through the object's create action: object-kanban offers no quick-add control.
  • object-master-detail-form-detail-sort-field-retired — page.component.object-master-detail-form.details[].sortField — a detail entry's authored line-position field → Nothing on the entry: delete the key. The line grid stamps each line's position into the child object's own field, derived from the child object: its first field named position, sort_order, sequence, line_no, line_number or sort. To keep the line order a drag-reorder sets, give the child object one of those fields (under the name the deleted key named, when it is one of them).
    • Why not automatic: The D2 conversion object-master-detail-form-detail-sort-field-removed deletes sortField from every object-master-detail-form detail entry, and the delete is lossless: the console stopped reading the authored override, and the line grid stamps the field it derives from the child object whatever the entry says. What the conversion cannot decide is where the line order lives. An entry whose key named a field the derivation does not pick — a name outside that list, or a second sort-named field after the first — saves its line order into the derived field instead, or nowhere when the child object has none. An entry that names relationshipField and at least one column and gives every column a type is kept exactly as authored: no child schema is loaded for it, so no line position is stamped and a drag-reorder is not saved, before and after the upgrade alike.
    • Done when: No object-master-detail-form detail entry carries sortField; the props lint reports one with the prescription. For each entry that had set it, the child object declares the field the line order is kept in under one of the derived names, and after a drag-reorder and save the lines reload in the order they were dragged into.
  • object-tenancy-organization-field-retired — object.tenancy.organizationField — the column a platform row is stamped from, as distinct from the column the object is walled by → tenancy.tenantField — one column that both walls the object and stamps its platform rows. The stamp-only divergence is a platform-internal fact now, kept for the platform's own credential table.
    • Why not automatic: The D2 conversion object-tenancy-organization-field-removed deletes the key from every object's tenancy block in author sources and on stored object rows, and the delete is lossless: the key's only readers were three platform-row writers, pinned by name to platform tables, so an application that declared it was never read. The judgment is what the declaration was for. An author who set organizationField to a column other than tenantField asked for platform rows (audit stamps, approval rows, automation-run records) to carry a different organization column than the one walling the data — and never got it. If the object's real tenant column is not organization_id, the fix is tenancy.tenantField, which moves the wall as well as the stamp; whether moving the wall is correct for that object is a data-isolation decision only its author can make.
    • Done when: No object declares tenancy.organizationField; the tenancy block refuses it with the prescription. Every object whose tenant column is not organization_id declares that column as tenancy.tenantField. A record created by a user of one organization is stored with that organization in the tenant column, a user of another organization cannot read it, and the audit stamp on the change names the same organization.
  • observability-cel-predicates-retired — metrics.slis[].successCriteria, the CEL predicate arm of the union (the structured { threshold, operator, percentile? } arm is untouched) / tracing.sampling.composite[].condition, the CEL predicate arm of the union (the structured filter arm is untouched). Both arms were reachable in two spellings: the bare-string shorthand and the { dialect: 'cel', source } envelope. Reachable wherever metadata is authored or stored: defineStack sources, an exported stack passed to objectstack validate, a POST body on a metrics or tracing config, and a row already sitting in sys_metadata → the structured arm each slot already carried, or your observability infrastructure. On successCriteria write the threshold rule — { threshold: 300, operator: 'lt', percentile: 0.95 } — which is the shape an SLO product consumes. On a composite sampling condition write a structured filter: a plain object of match criteria carrying no dialect key, e.g. { service: 'api', attributes: { 'http.route': '/v1/orders' } }. ⚠️ Neither replacement is mechanical, and neither is a like-for-like: a criterion or a sampling rule the structured shape cannot express has no home in application metadata at all and belongs in the SLO product or the OpenTelemetry sampler configuration that actually evaluates it
    • Why not automatic: DECLARED, DOCUMENTED, AND EVALUATED BY NOTHING — which is why this is a semantic TODO rather than a mechanical strip. Both arms parsed, normalized a bare string to { dialect: 'cel', source }, registered and were served back, and no service, plugin, runtime or CLI path ever read either key: an identity scan over the whole tree finds every hit for successCriteria, ServiceLevelIndicatorSchema and TraceSamplingConfigSchema outside packages/spec/src to be a generated artefact or prose, and inside it the only readers are the schemas' own unit tests plus the two census tests that enumerate expression slots. So an author — very often an AI reading the generated reference page, ADR-0033 — who wrote successCriteria: 'p95 < 300ms' got a green parse and no signal, indistinguishable from a predicate that ran and answered. ADR-0049 enforce-or-remove, ruled A by the maintainer on 2026-09-18: by the standing criterion that a declared-but-unread capability is kept only when mainstream platforms in the domain have it, application platforms do not carry SLI success criteria or trace-sampling conditions as authorable application metadata — that lives in observability infrastructure (SLO products, OTel sampling policy) and is structured there, not a free expression. The cron-declared-unwired family was retired outright under the same ADR after the same measurement. A mechanical D2 strip was weighed and declined: a predicate is an intent no threshold/operator pair or attribute filter records, so stripping the key would delete what the author meant and leave no trace of which SLI or which sampling branch lost it — exactly the judgment a semantic TODO exists to hand back. ⚠️ And a strip here is not merely lossy, it is INVALID: successCriteria is a REQUIRED key, so removing it leaves an SLI that no longer parses, and a composite sampling branch that loses its condition declares no condition at all — inert today, and the moment a sampler is wired it reads as UNCONDITIONAL. That is the difference from the two error-map precedents this retirement copies its MECHANISM from — crypto.hash on HookBodyCapability and managedBy: 'system' — both of which also registered a D2 conversion, because for each of them a mechanical rewrite existed. Here none does, which is what makes D3 the right disposition rather than merely an available one. ⚠️ The structured arm of each union is NOT decided here: it is equally unread today, and it is measured on its own card. ADR-0087, ADR-0058 D7, ADR-0049.
    • Done when: Sweep every authored metadata source and every sys_metadata row of the metrics and tracing config types for a CEL predicate at the two slots — in BOTH spellings: a bare string, and an object carrying a dialect key. For each hit, decide per the replacement note whether the intent is expressible as the structured shape (write it) or belongs in your observability stack (delete the key and move the rule there). ⛔ Do not translate a predicate into a threshold by guessing the number — nothing was evaluating it, so there is no behaviour to preserve and a wrong number is worse than an absent one. Two proofs. (1) objectstack validate is clean on a stack authored in config files: a surviving predicate is refused at the slot with the retirement prescription. ⚠️ TWO CHANNELS, and they do not cover the same set — measured, not assumed. tsc catches the BARE-STRING spelling at both slots, and the { dialect, source } envelope at successCriteria only (the structured arm is a closed object literal, so the envelope is an excess-property error). It does NOT catch the envelope at condition: the surviving arm there is a record of string to unknown, which admits { dialect, source } structurally, so that one spelling compiles and is refused at PARSE by the arm's dialect rule. ⛔ Do not read a clean tsc as a clean sweep of condition. The PRESCRIPTION divides differently again: it reaches the author for every refused spelling at condition, and for the string spelling only at successCriteria, where the envelope is refused by the structured arm's own missing-key issues (threshold, operator). All three legs are pinned in the schemas' unit tests. (2) For stored rows, load the tenant and confirm every metrics and tracing config still rehydrates: a row carrying a predicate at either slot now fails its parse at the load seam and is reported there, naming the slot. A row whose successCriteria is a structured rule and whose sampling condition objects carry no dialect key parses byte-identically to before — the retirement removes accepted shapes and adds none.
  • package-api-contracts-unmounted-entries-retired — api.PackageApiContracts.upgradePackage / api.PackageApiContracts.resolveDependencies / api.PackageApiContracts.uploadArtifact — the three contract-map entries that bound POST /api/v1/packages/upgrade, POST /api/v1/packages/resolve-dependencies and POST /api/v1/packages/upload → nothing — no route serves any of the three paths, so there is no entry to read instead. Delete every read of PackageApiContracts.upgradePackage, PackageApiContracts.resolveDependencies and PackageApiContracts.uploadArtifact, and every URL built from them or from the three hard-coded paths: a request to any of them was never answered. The per-route request/response schemas (PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema) stay published, bound to no route. The four surviving entries (listPackages, getPackage, installPackage, uninstallPackage) are unchanged. If the platform later serves a package upgrade, dependency-resolution or upload route, its entry arrives in the same change that mounts it.
    • Why not automatic: Maintainer ruling of 2026-09-23 (option A: retire the three contract-map entries that name paths nothing mounts). The contract map is the declaration SDKs, codegen and AI clients are entitled to trust, and three of its seven entries named paths the composed runtime mounts nowhere: the package dispatcher has no branch for a single-segment POST under /packages and @objectstack/rest mounts only /packages/publish there, so all three answered handled=false while the four surviving entries answer 200/201 (measured on one HttpDispatcher over a real SchemaRegistry) — and the generated reference page printed all three as live endpoints. Unlike installPackage (rebound by an earlier fix onto the serving POST /api/v1/packages), no serving door existed to rebind them onto, and mounting three capabilities with zero measured pull was ruled out (ADR-0049 enforce-or-remove). Zero consumers measured at the retiring PR's base: across this repository the three paths occur only in the declaring file, its unit test and the generated page, and the pinned objectui checkout names none of the three keys, none of the paths and not PackageApiContracts itself. A contract-map entry is not metadata — nothing authors, stores or parses it — so there is no source a D2 conversion could rewrite, and the removal is recorded here.
    • Done when: No code reads PackageApiContracts.upgradePackage, .resolveDependencies or .uploadArtifact from @objectstack/spec or @objectstack/spec/api — each is a TS2339 property error after upgrade, and at runtime the key is absent (pinned in api/package-api.test.ts together with the rule that no surviving entry is bound to any of the three paths). No client, route table or generated artefact of yours still names POST /api/v1/packages/upgrade, /resolve-dependencies or /upload. No metadata document needs editing. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever mounted the three paths or built a route from the entries, so every request answers exactly as before — the removal retracts a false claim, not a capability.
  • package-install-request-unknown-keys-refused — api.installPackage request body, WRAPPED form — an undeclared TOP-LEVEL key beside manifest on POST /api/v1/packages (PackageInstallRequestSchema, the wrapped branch of PackageInstallBodySchema) → the declared wrapped body: manifest, plus any of the declared install options (settings, enableOnInstall, overwrite, platformVersion, artifactRef). A misspelled option is respelled as the option it meant — enabledOnInstall → enableOnInstall, which the refusal itself offers — and any other undeclared key is removed. The bare form (a manifest as the whole body) is unchanged: it was already closed, and it still carries no install options.
    • Why not automatic: One rule for the whole install contract (the maintainer's ruling of 2026-09-27, option A: the wrapped form refuses an unknown top-level key by name). The manifest and the bare form already refused an unknown key by name; the wrapped top level was the one position still declared strip mode, so { manifest, enabledOnInstall: false } — a misspelled enableOnInstall — parsed green with the key DROPPED, and the install door, which answers exactly what this declaration says since it parses the whole body (c02fa1276), installed the package ENABLED: the caller's explicit false inverted, with no word said. The sentence that had forbidden this close rested on «the declaration must not refuse a body the door answers 201 to», which held only while the door did not parse its body; with the door answering per declaration the premise became circular and constrains nothing. No alias and no grace window. Not losslessly convertible: an unknown key has no mapping target, and auto-deleting it would repeat the silent drop this closes, so each occurrence needs the caller's decision — respell or remove. First-party reach measured before the close: the SDK install call sends only manifest, settings, enableOnInstall and overwrite, and the objectui package dialog sends only { manifest }, so no in-repo caller breaks. Out-of-repo callers are NOT MEASURED — a caller that sends a private top-level key now gets a 400 naming it.
    • Done when: Every wrapped install body carries only manifest and declared install options. A body with any other top-level key is refused 400 / VALIDATION_ERROR at POST /api/v1/packages with the key named and nothing installed, and PackageInstallRequestSchema answers the same body with one unrecognized_keys issue at the top level naming the key. { manifest } alone, and manifest with every declared option, parse and install exactly as before.
  • package-manifest-version-grammar-enforced — PackageManifestSchema.version (marketplace/package-version.zod.ts) — the versionkey inside the manifest snapshot frozen intosys_package_version.manifest_json at publish time → a SemVer 2.0.0 string matching SEMVER_2_0_0_VERSION_PATTERN (kernel/version-grammar.ts). This key was a bare z.string(), so it is the one carrier where the grammar is entirely new: latest, v1.0.0, 1.0, the empty string, a trailing space and 2.0.0-beta.1extra! were all accepted and sealed into a published snapshot, and each is refused now. A dist-tag becomes the version it pointed at (latest → 1.4.2); a v-prefixed string drops the prefix (v1.0.0 → 1.0.0); a two-segment string gains its patch (1.0 → 1.0.0).
    • Why not automatic: A downstream told "the spec validated it" got no validation at all from this carrier. The sibling key it belongs to — PackageVersionSchema.version, the row this manifest hangs off — enforced a grammar the whole time, so the SAME release was judged by a rule in one field and by nothing in the adjacent one, and the unjudged value is the one that got frozen and shipped. That is the shape Prime Directive #10 refuses: a declaration advertising a constraint the runtime never applies. The canon ruling gave every carrier of this concept one grammar, and a carrier with no grammar could not be left out of it without keeping the hole open under a new name. Why a D3 semantic TODO rather than a D2 conversion: the repairs above are one-directional guesses. latest names whichever release was current when the snapshot was sealed, which is not recoverable from the snapshot, and 1.0 may mean 1.0.0 or the newest 1.0.x — a transform that picked either would seal a different release under the same checksum.
    • Done when: Every package your registry serves still installs, and manifestJson.version parses for each one. The check is cheap and exhaustive: read manifest_json on each sys_package_version row and test its version against the grammar. A row that fails was already carrying a value no other carrier would have accepted — confirm what release it was meant to name before choosing the replacement, because the snapshot cannot tell you, and republish rather than editing a frozen snapshot in place. In this repository the measured count of such rows is zero.
  • package-rollback-response-retired — api.packageRollbackResponse (PackageRollbackResponseSchemain api/package-api.zod.ts — 1 def, 3 exported names:PackageRollbackResponseSchema, PackageRollbackResponse, PackageRollbackResponseParsed— plus thePackageApiContracts.rollbackPackagecontract-map entry that bound it toPOST /api/v1/packages/:packageId/rollback) → RollbackToPackageCommitResponseSchema (api/package-lifecycle.zod.ts) — the transcription of what the live route actually answers: the dispatcher routes POST /packages/:id/rollback (body { commitId }) to rollbackToPackageCommit, the ADR-0067 COMMIT rollback, whose declared return is { success, revertedCommits: string[], failed: Array<{ commitId, error }> }. Consumers of the retired type were reading a VERSION-rollback shape (restoredVersion) the route has never answered; read revertedCommits/failed instead. PackageRollbackRequestSchema stays published (ruled out of the retirement), bound to no route.
    • Why not automatic: Maintainer ruling of 2026-08-27 on the client SDK's unbound response contracts, sub-question 3A: retire this false declaration first, then author the true one. The schema declared a version rollback — { success, restoredVersion?, message? }, matching its file header "Rollback a package" — while the live path it was contract-bound to serves the ADR-0067 commit rollback: a different operation with a different result. Binding it in the SDK would compile and be false (the change that typed the SDK's un-annotated return values left a compile-time guard against exactly that substitution). Zero consumers measured across objectstack, objectui and cloud (the ruling's own survey, re-verified at the retiring PR's base): only its own unit test and that negative guard. A published declaration that outran the implementation is the hazard of response bodies never checked against the schemas that declare them, realised in the opposite direction — not "no declaration" but a WRONG one — and it is retired BEFORE the true schema is authored so no window exists in which both claims are published.
    • Done when: No code imports PackageRollbackResponseSchema, PackageRollbackResponse or PackageRollbackResponseParsed from @objectstack/spec or @objectstack/spec/api — every one is TS2305 after upgrade (pinned by runtime namespace probes in api/package-api.test.ts). PackageApiContracts carries no entry whose path is /api/v1/packages/:packageId/rollback (same pin). No metadata document needs editing: the schema was reachable from no metadata-type binding, stack collection or /meta door. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever registered routes or generated SDKs from the contract entry, and the route's handler emits the same bytes before and after — the retirement removes a false claim, not behaviour.
  • package-uninstall-environment-wide — the organizationId and allTenants members of the deletePackage request of @objectstack/metadata-protocol (DeletePackageRequest), the two TENANT_SCOPE_REQUIRED refusals of deletePackage, and the organization-scope refusal of DELETE /api/v1/packages/:id on the runtime dispatcher → call deletePackage({ packageId }) with neither key: the uninstall removes every row bound to the package in this environment. A request still carrying organizationId or allTenants is refused with 400 INVALID_REQUEST and removes nothing; drop the key and retry. Who may uninstall is the package door's operator gate
    • Why not automatic: The guard existed because an uninstall naming no organization once matched every organization's rows, so a cross-tenant uninstall had to be declared (allTenants: true) and a scoped one named (organizationId). ADR-0131 D6 removes that premise: no metadata write lands organization-scoped any more, so a package's rows belong to the environment and an organization names nothing. The HTTP door never sent allTenants, so an operator with no active organization could not uninstall over HTTP at all. The keys are refused rather than ignored, because a caller still sending one believes it scopes the uninstall. Legacy organization-scoped rows bound to the package are removed with it, as the declared cross-tenant uninstall removed them, rather than stranded for the promotion ceremony.
    • Done when: DELETE /api/v1/packages/:id by a manage_metadata caller with no active organization succeeds and removes every sys_metadata row bound to the package, environment-wide and legacy organization-scoped alike; the same call with an active organization behaves identically. A deletePackage request carrying organizationId or allTenants (true or false) answers 400 INVALID_REQUEST and changes nothing. No response carries TENANT_SCOPE_REQUIRED. Remove both keys from every deletePackage caller, and any deploy script that passed allTenants: true.
  • package-version-row-semver-2-0-0 — PackageVersionSchema.version (marketplace/package-version.zod.ts) — the versioncolumn of asys_package_versionrow, and throughCreatePackageVersionRequestSchema.version, which references it, the version a draft release is created with → a SemVer 2.0.0 string matching SEMVER_2_0_0_VERSION_PATTERN (kernel/version-grammar.ts). Two changes, opposite in direction. ⭐ WIDER: suffix identifiers may now carry either ASCII case, because SemVer 2.0.0 is case-preserving — 1.0.0-Beta.1 and 1.0.0+Build.5 are accepted where this key used to demand lowercase, and the plugin boot path has always accepted them. ⛔ NARROWER: the forms the standard forbids are refused — 01.1.1 (§2), 1.0.0-0123 and 1.0.0-alpha..1 (§9), 1.0.0+. (§10).
    • Why not automatic: This key's own docstring advertised 2.0.0-beta.1 as an example of itself while a sibling carrier of the same concept refused that exact string — the contradiction the canon card was filed over. The lowercase restriction was the narrowest published accept set of the four and had no standard behind it: it made a release row refuse a version the runtime that loads the release accepts, so a publisher could be turned away for a capitalisation the loader would never have noticed. Why the narrowing is a D3 semantic TODO rather than a mechanical rewrite: a published version row is immutable by contract — manifestJson and checksum freeze on transition to published — so a stored degenerate version is not edited in place at all. It is republished under a version that sorts, and whether the old row should be deprecated or left standing is a release decision the chain cannot make.
    • Done when: Publishing and installing every release you have works unchanged. The widening needs no action and can be confirmed cheaply: a mixed-case prerelease that used to be refused at publish now creates a draft. For the narrowing, list your sys_package_version rows and check each version against the grammar — a leading zero in a numeric segment, or a doubled or trailing dot in a suffix, are the only shapes affected. Any row that fails stays readable and installable; what it can no longer do is receive a NEW draft at that spelling, so cut the next release at a version that sorts.
  • packages-list-pagination-retired — api.listPackages limit and cursor — the two query parameters of GET /api/v1/packages declared by ListInstalledPackagesRequestSchema. The same entry covers the limit default: the request schema no longer declares default(50) → the status, type and enabled filters — this route answers the whole installed set 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, and the response nextCursor it would have paired with was never emitted. Callers that looped on it were re-reading the first and only page. For the removed limit default, there is nothing to send instead and nothing to restore: the server has never capped this list, so a caller that omitted the key received every installed row before this change and receives every installed row after it. A client that sized a buffer to the declared 50 should size it to the installed set instead
    • Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-09-13, route 2 of three; routes 1 — build paging — and 3 — refuse unknown names — were considered and refused). limit and cursor were declared on the request and honoured on neither: the serving door filters on status, type and enabled and then returns every remaining row, and no emit site has ever written the response half nextCursor. limit is the sharper of the two because the repo's own ingress rule names it as the parameter whose silent drop is worst, and it is the silent-WIDENING half that was live: a caller asking for one row was handed the whole table alongside a hasMore: false that agreed with it. The .default(50) goes with the key because the FICTION WAS THE MECHANISM, not the number: nothing parses a query string through this schema, so the default has never stamped anything onto anything, while a reader of the published contract was entitled to believe an unparameterised list is capped. Re-spelling it as the real cap was not available — there is no cap. Pagination was removed rather than implemented because the installed-packages list is a small bounded collection and paging is not part of its meaning: route 1 would have grown a cursor protocol for a table of tens of rows, and the dispatch checked first whether a platform-wide cursor convention already existed that this door could have joined by reuse. It does not — no REST list door in the tree paginates, the one encode/decode cursor pair in the repo belongs to the storage-adapter list contract and is imported by no door, and the travel of this platform is the other way: data.query.cursor and api/ListNotificationsRequest:cursor were both retired before this one, for the same reason. Route 2, and the bookkeeping splits exactly as the notifications cursor retirement did. There IS a tombstone: the schema is non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a generated client kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (ADR-0104). So both keys are retiredKey(), typed never for tsc and raising the prescription at any parse, and both are registered in RETIRED_KEYS_BY_MAJOR[18]. There is NO D2 conversion: a conversion rewrites an authored source or a stored sys_metadata row, and this shape is HTTP-only — nobody authors a ListInstalledPackagesRequest and nothing persists one. There is no acceptRetiredDefaultResidue stage either, for the same reason one layer along: nothing ever parsed this schema, so the retired default materialized into no artifact and there is no residue to accept. The same card closes the divergence in the OTHER direction, which is not a migration for anyone and is recorded here only so the two are not read apart: type (list), version (by-id) and keepData (uninstall) are query parameters the doors already executed and no request schema declared, and they are now declared where they are executed. No accept set moves — the doors served them before and serve them identically now. ADR-0049 / ADR-0087.
    • Done when: No caller sends limit or cursor to GET /api/v1/packages: writing either on a ListInstalledPackagesRequest is a tsc error (the input type is never), which is the enforced channel, and any value reaching a parse raises the prescription rather than a generic unrecognized-key issue. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying ?limit=1&cursor=x is IGNORED, not refused — the door reads 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. hasMore stays the constant false it already was and is now true by construction rather than by coincidence — with no request-side way to ask for a page there can be no next one — and nextCursor stays absent. A caller that omitted limit receives every installed row, exactly as it did before.
  • page-assigned-profiles-audience-to-permission-set — ``page.assignedProfiles — the per-page audience list (REMOVED) → the object's permission sets, bound to people through positions. The page shows DATA; gate that data with the permission sets on the objects it reads (objects.<name>.allowRead and the field-level bits), and bind each set to the people who should hold it through a position (sys_position_permission_set). There is no per-page audience key to move the list into, and ADR-0090 D2 deleted the Profile concept the old list was written in, so each name in a retired assignedProfiles list has to be re-expressed as a permission set + position pair.
    • Why not automatic: The D2 conversion page-assigned-profiles-removed STRIPS the key mechanically, but the strip is not the whole migration and must not read as one: the author who wrote the list was declaring an intent ("only these people see this page") that the platform never honoured. Measured at the ruling: zero readers in this repository and zero in objectui — no renderer, route or metadata read door consulted the key — so the page has been open to every caller who could reach it for as long as the key existed. Deleting it therefore changes no behaviour and closes no hole; it makes an unkept promise stop being made. Which permission set corresponds to a given profile name is a judgement no walker can derive, which is why this is a TODO rather than a rewrite.
    • Done when: No page metadata carries assignedProfiles (the D2 conversion page-assigned-profiles-removed strips it from authored sources on a chain replay; os migrate meta --stored covers rows already at rest). For every page that carried one, each name in the old list resolves to a permission set held by the intended people through a position, and a caller OUTSIDE that audience, signed in, is refused the data the page reads — verified against the running deployment, not against the metadata alone. A caller who was previously outside an assignedProfiles list and could nonetheless open the page is the pre-existing state, not a regression introduced by the removal.
  • page-component-responsive-retired — page.components[].responsive — the per-breakpoint columns / order / hiddenOn block, and the exported ResponsiveConfig shape with its breakpoint maps → The sibling responsiveStyles (ADR-0065): per-breakpoint CSS maps compiled to id-scoped CSS at render — for example responsiveStyles: { xsmall: { display: 'none' } } to hide a component on the narrowest screens.
    • Why not automatic: The D2 conversion page-component-responsive-removed deletes responsive from every page component wherever one can be authored, and the delete is lossless: no renderer ever read the block, so the per-breakpoint columns, order and visibility it declared parsed, validated and did nothing. This was also the block an earlier tombstone prescribed as the live alternative for dashboard widgets, so an author who followed that advice moved an inert key to an inert key and may still believe their page adapts to small screens. What remains is theirs to decide: whether the layout they declared is one they still want, and if so how to say it in CSS that is applied — hiddenOn maps to a display rule per breakpoint, while column spans and order are layout choices with no one-to-one CSS rewrite. Code that imported the retired shape (ResponsiveConfigSchema, BreakpointName, the breakpoint maps) must drop the import; nothing replaces it.
    • Done when: No page component carries responsive; the parse refuses it, and no code imports the retired shape (each such import is a compile error). Each page renders exactly as it did before the upgrade at every breakpoint. Where the author re-expressed an intended adaptation through responsiveStyles, resizing the viewport across the named breakpoints shows it — a component declared hidden on the narrowest breakpoint is absent there and present above it.
  • page-header-breadcrumb-retired — page.component.page:header.breadcrumb — the page header's "Show breadcrumb" switch → Nothing: delete the key, whether it was true or false. The navigation trail is drawn once, by the app shell's header, and is unchanged.
    • Why not automatic: The D2 conversion page-header-breadcrumb-removed deletes breadcrumb from every page header, and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. The slot was the only thing either value changed — present for true and for an absent key, gone for false — so a header that said false reads as absent after the strip and shows the empty slot's spacing again until the renderer stops drawing it. What the conversion cannot decide is whether a page needs a trail of its own: inside an app the shell already draws one, and a page outside the shell that needs one is a feature to ask for, not a key to keep.
    • Done when: No page header carries breadcrumb, and the props lint reports one with the prescription. Every page shows the same navigation trail in the app shell's header as before the upgrade, and each page header shows the same title, subtitle and actions.
  • page-requires-non-compiled-kind-refused — page.requires on a page whose kind is react, full or slotted — a page that omits kind included, since its kind is full → Nothing: delete the key. On an html page (and its deprecated jsx alias) the platform derives requires from the source at save and stores it, so it is omitted there too; on a react, full or slotted page nothing ever derived or enforced it, and nothing takes its place.
    • Why not automatic: PageSchema admitted requires on every page kind, but the platform derives it only on the kinds whose source the metadata save door compiles: saving an html page (alias jsx) on a server that has the deployment's SDUI component manifest compiles the source, stores the plugin namespaces it uses as requires, and refuses a written list that disagrees. A react source is executed at render and never compiled at save, and full and slotted pages have no source, so on those kinds nothing derived the key, the Studio page editor dropped it on every save, and its one reader was a load-time warning. The maintainer ruled (2026-10-03) that the key is accepted only on html and jsx pages. The parse now refuses it on react, full and slotted pages, and a page that omits kind is a full page: objectstack validate, the metadata save door (a 422) and every other door that parses a page name the key, the page's kind and the compiled kinds. An empty list is refused like a full one, because the key is what is refused. No page body authoring the key on those kinds was measured in this repository, cloud, hotcrm or objectui. The D2 conversion page-requires-non-compiled-kind-removed deletes it from such pages: stored rows and built artifacts replay it at load, with a notice, and objectstack migrate meta --from 17 lists the edit for authored sources, which the parse refuses until it is made. The delete loses nothing a page did. What it cannot decide is whether the page should have been an html page: an author who wrote the list to have plugin presence checked gets that check only on an html page, where the platform derives the list from the source and judges it at save and load.
    • Done when: objectstack validate reports no issue at a page's requires path: no react, full or slotted page, and no page that omits kind, carries the key, and each html or jsx page either omits it or carries exactly the list its source compiles to. Saving each formerly affected page through the metadata API succeeds instead of answering a 422 that names requires. Replaying objectstack migrate meta --from 17 over the edited source lists no page-requires-non-compiled-kind-removed edit, and every page renders as it did before the upgrade.
  • page-slots-details-beside-tabs-refused — page.slots.details authored beside page.slots.tabs on one page — either slot a single component or an array, an empty details array included, whatever the page kind → One tabs slot whose items carry the details body: the record:details component (its sections and hideFields unchanged) as the children of a tabs item, the first one by convention — e.g. { label: 'Details', children: [{ type: 'record:details', properties: { … } }] } — and no details slot. To keep the synthesized tab strip with the authored details body in its Details tab instead, delete slots.tabs.
    • Why not automatic: The slot map declared details and tabs as two independent optional slots, and they are not: on a slotted record page details replaces the body of the Details tab, that tab lives inside the synthesized page:tabs strip, and tabs replaces the whole strip. The console's default-page synthesizer therefore reads tabs and never reads details when both are authored, so the pair passed PageSchema.parse, objectstack validate and the metadata save door while the authored details body — its sections, its hidden fields — silently never applied. The platform's own sys_user_detail page authored both, so its Identity and Audit sections never showed and the ban columns it hides were never hidden by it; it now carries its record:details as the first tabs item. The parse now refuses the pair at slots.details, naming both slots and the fix: definePage, defineStack (STACK_SCHEMA_INVALID, 422), objectstack validate and the metadata save door (422 INVALID_METADATA). Measured reach before the narrowing: in this repository only sys_user_detail authored the pair (no example app page does), and hotcrm's one slotted page authors header and discussion only. ⚠️ No D2 conversion: which tab item carries the details body, and under which label, is the author's decision, and moving it makes visible a body that never rendered — a change to the page, not a respelling. ⚠️ A page row already stored with the pair is replayed unchanged at load (no conversion touches it), so it renders as before — the authored tabs, without the details body — while its read diagnostics name the pair and saving it again is refused until the details body moves. ADR-0087.
    • Done when: Grep every page in defineStack pages sources, exported stacks and every page row in sys_metadata for a slots map carrying both a details and a tabs key. For each, move the details component(s) into the tabs items — as the children of a tab item, the first one by convention, with its sections and hideFields unchanged — and delete slots.details, or delete slots.tabs to keep the synthesized tabs. Then objectstack validate reports nothing at pages.N.slots.details, saving each formerly affected page through the metadata API succeeds instead of answering a 422 that names slots.details, and the record page shows the authored details body (its sections, without its hidden fields) in the tab that now carries it. A page authoring only one of the two slots parses and renders byte-identically to before.
  • permission-restore-purge-bits-retired — permission.objects.<object>.allowRestore / permission.objects.<object>.allowPurge — the object-permission bits for undelete and hard delete → (removed — the restore and purge operations they claimed to gate do not exist.) A dispatched restore or purge is denied fail-closed by the permission evaluator's destructive-operation backstop for every principal. The bits return together with the operations they gate. allowTransfer, the third lifecycle bit, is enforced and stays.
    • Why not automatic: The D2 conversion permission-allow-restore-purge-removed deletes both keys from every object permission in author sources (both values, true included), and the delete is lossless: no destructive lifecycle verb is in the engine's dispatch vocabulary, so a grant delivered nothing and a denial locked nothing — every such request was, and stays, denied. The judgment is about what people believed. An admin who wrote allowPurge: false believed a lock existed; an admin who wrote allowPurge: true for a compliance role believed that role could hard-delete a record on request — a GDPR erasure, for instance. Neither was ever true. Any process, runbook or audit statement that relies on either belief needs another path, and deciding that path is a governance decision no conversion can make. Separately, an artifact built by the 17.x toolchain carries both keys materialized as the literal false; that one value is tolerated at load as inert residue and stripped, and every other value — true, or a string or number spelling — is refused with the prescription.
    • Done when: No authored object permission carries allowRestore or allowPurge; the parse refuses any value but the tolerated false residue. Access decisions are unchanged: a request for restore or purge is denied for every principal before and after the upgrade, and allowTransfer behaves as before. Every documented process that assumed a restore or purge grant — an erasure-request runbook, an access review, an audit control — names the mechanism it actually uses instead.
  • permission-rls-tags-retired — permission.rowLevelSecurity[].tags — the free-form categorization tags on a row-level security policy → (removed — no mainstream platform tags a row-level policy, and nothing here ever read one.) A policy is identified by its name and its object, and reported by those and its predicate; its purpose belongs in description. Whom a policy applies to is decided by positions, never by a tag.
    • Why not automatic: The D2 conversion permission-rls-tags-removed deletes tags from every row-level security policy in author sources and in stored permission rows, and the delete is lossless: the RLS compiler never consulted the key and nothing else acted on it — no report, audit filter or review queue selected on it — so no access decision changes. The judgment is about what people believed. An admin who tagged a policy gdpr or pci may have expected a compliance report, an audit filter or a review queue to pick it up; none ever did. An author who wrote a tag such as managers_only may have believed it scoped the policy; it never did — only positions narrows whom a policy applies to. Any report, runbook or control that relies on either belief needs another path, and choosing that path is a governance decision no conversion can make.
    • Done when: No authored or stored row-level security policy carries tags; the parse refuses the key with the prescription. Access decisions are unchanged: every policy admits and refuses exactly the rows it did before the upgrade. Every policy whose tag expressed an audience has that audience in positions, and every compliance report, audit filter or review process that assumed policy tags names the mechanism it actually uses instead.
  • platform-global-object-organization-column-retired — OrgScopingEntitlement.platformGlobalObjects — an object a deployment declares platform-global no longer keeps its injected organization_id column with the organization wall stood down over it; on that deployment the injected-columns plan withholds the column, and the engine registers the object with no organization_id and declaring systemFields.tenant false → Nothing to rewrite where no deployment declares the object. On the declaring deployment, the declared object has no organization_id: rewrite any authored filter, list-view column, report grouping, formula or seed key that names organization_id on it, or drop it; a write naming it is refused INVALID_FIELD and a filter INVALID_FILTER. The object is governed by object permission, not by the organization wall
    • Why not automatic: ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope". Before this, the declaration stood the security layer's organization wall down for the object while the column stayed, so the SQL driver went on scoping a read by the caller organization that the wall had stopped scoping — measured on a booted kernel with a fixture provider, before the change. ADR-0131 retires that stand-down ("replaced by D7's no-column"). The engine reads the declaration at its plugin start(), before the first schema sync: every plugin init() has completed by then (ADR-0116, the Phase 1/2 split) and the org-scoping provider registers the service in its init(), declared in providesServices, so an object registered earlier is re-planned before its table is created. An absent declaration leaves every object's plan byte-identical; a malformed one is refused loudly and declares nothing. An object that declares its own organization_id keeps it and stays walled on it. Existing databases: schema sync is additive, so the physical column stays on a declaring deployment and the boot drift report names it orphaned; the operator removes it, and nothing moves at boot.
    • Done when: On a deployment whose org-scoping service declares an object platform-global, the object is registered and provisioned with no organization_id, the security layer composes no organization wall on it, and a read carrying the caller organization reaches every row of its table; a non-declared object on the same deployment keeps its column and its wall. With no declaration, or a malformed one, every object keeps its column.
  • platform-timezone-columns-iana-domain-refused — The two platform audit time-zone columns — sys_job.timezoneandsys_report_schedule.timezone — carrying a string that is not a member of the IANA time-zone database (Asia/Shangai, Europe/Munich, UTC+8, PST). → The canonical IANA zone id the deployment meant, written in the spelling the tzdb uses: Asia/Shanghai, Europe/Berlin, America/Los_Angeles. UTC is a member and is admitted — membership is the shared Intl.DateTimeFormat probe, never the Intl.supportedValuesOf('timeZone') enumeration, which omits UTC and would refuse the one fallback this contract names. ⚠️ A non-member is RE-AUTHORED, never repaired on the deployment's behalf: the correct zone behind a typo is a fact only the deployment holds, which is what makes this entry semantic rather than a D2 conversion.
    • Why not automatic: The change validating sys_job.timezone and sys_report_schedule.timezone against the IANA domain gave both columns valueDomain: 'iana_time_zone', which had been declared on sys_business_unit.timezone / sys_organization.timezone since those two objects first gained a timezone column. It is a WRITE-TIME narrowing of the min/max/maxLength transition-gate class: a value already stored outside the domain is never re-read against it, no DDL is planned, and objectstack migrate meta has nothing to rewrite — the changeset that shipped it says so in those words, and this entry does not contradict it. What the changeset had no way to carry is that a deployment holding such a value now has WORK TO DO: the next write of that row is refused with the ADR-0114 field code value_domain, and until then sys_report_schedule.timezone keeps doing the thing the narrowing exists to stop — ReportService.nextRunAt hands a non-member zone to croner, whose throw was caught and turned into a silent fall back to interval_minutes, so "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever". Not a throw and not a fall back to UTC: the wrong instant, permanently. ⛔ It went out with NO **BREAKING** marker, so the repo's own breaking-change detector classified it non-breaking and asked for no ADR-0087 disposition at all — measured on the shipped changeset. A ruling closed that hole (the declaration now carries a (narrowing) arm the gate reads instead of a prose banner) and this row is the other half of the same ruling: the narrowing that already shipped is RECORDED, ⛔ not re-released and ⛔ not ratified in silence. Maintainer ruling 2026-09-07, option B: the clause-② declaration gains a widen / narrow arm that the ADR-0087 classifier reads, and each narrowing that already shipped without a banner is recorded as one ledger row. The direct precedents for registering a change no transform can apply are schedule-flow-acting-organization-required (protocol 18) and rest-requireauth-default-flip (protocol 12) — behaviour-only, a deployment judgement, registered anyway because the prescription is real.
    • Done when: Every sys_job.timezone and sys_report_schedule.timezone value stored in the deployment is an IANA member. The one-line fix per offending row: write the canonical zone id (UPDATE … SET timezone = 'Asia/Shanghai'), or clear the column — sys_report_schedule documents a UTC default and sys_job has no reader at all. Rows already holding a member parse and behave byte-identically to before; rows holding none are readable, are returned unchanged, and fail only on their next WRITE. A report schedule that was silently running on interval_minutes resumes its cron cadence once its zone is a member — that resumption, not the absence of an error, is how the fix is verified. ⚠️ The two columns' maxLength (100 vs 64) and defaults (none vs UTC) are deliberately still unconverged and are NOT part of this entry; no member is longer than 32 characters on the current Node baseline, so neither bound admits anything the domain does not.
  • plugin-auto-restart-never-reinitialised — ``PluginHealthCheck.autoRestart, PluginHealthCheck.maxRestartAttemptsandPluginHealthCheck.restartBackoff, and the PluginHealthMonitor.attemptRestart path that read them → Poll PluginHealthMonitor.getHealthStatus(pluginName) / getHealthReport(pluginName) and act on unhealthy / failed in the HOST. There is no in-tree replacement for the keys, because restarting a plugin is the host's job in this host-driven library and the monitor could not do it even in principle: Plugin.init(ctx) needs a PluginContext, which only the kernel constructs and which it exposes to nobody (ObjectKernel.context is private, KernelBase.createContext is protected). Recreate the kernel, or let your supervisor restart the process — whichever level actually owns the plugin's lifetime. The monitor reports; it does not act.
    • Why not automatic: ADR-0049 enforce-or-remove, applied one class over from the two hot-reload retirements in the same host-driven lifecycle library — the file-watching placeholder whose startWatching logged success while watching nothing, and the 'disk' / 'distributed' state strategies that fell back to memory in silence — and for a sharper reason than either: this key HAD a reader that acted, and what it did was not what the key declared. attemptRestart called plugin.destroy() and stopped there. The comment above the call read "Call destroy and init to restart", and init appeared in health-monitor.ts ONLY inside that comment. So what a plugin actually got was: destroy, a log line reading 'Plugin restarted', status recovering, and periodic health checks continuing to run against the destroyed instance — which the default check when no checkMethod resolves ({ name: 'plugin-loaded', status: 'passed' }) passes indefinitely. The TERMINAL report on a destroyed, never-re-initialised plugin was therefore healthy, reproduced at ee3595cefd with successThreshold: 3 as failed -> recovering (destroyed=1, alive=false) -> recovering -> recovering -> healthy (destroyed=1, alive=false). The fix that made successThreshold bind from every status that records a failure made that MORE convincing rather than less, because reaching healthy now costs successThreshold CONSECUTIVE passing rounds, so the plugin has to earn a declared number of passes to be misreported. Meanwhile restartAttempts was incremented as though a restart had occurred, and maxRestartAttempts / restartBackoff scheduled further "restarts" of a plugin that was never brought back up. Neither of the other two ADR-0049 states was available: ENFORCE would have to BUILD the restart, and the class cannot host one — Plugin.init(ctx) needs a PluginContext, and the only two plugin.init(...) call sites in the tree are the kernel's own boot loops over the full plugin list, with a context that is private on ObjectKernel and protected on KernelBase, so a host-provided re-init hook would have had nothing to call (positive control: the same scan resolves five real non-test plugin.destroy() call sites, so it sees lifecycle drivers). Building that API for a caller that does not exist — no runtime constructs PluginHealthMonitor, which is why the maintainer retired its declarative config container on 2026-08-25 and kept the classes as a host-driven library — is exactly the speculation ADR-0049's staged decision names as the wrong default at this milestone, where the shippable liability is the false promise and not the missing feature. EXPERIMENTAL requires a roadmap, and a scan of the whole docs/ planning + ADR corpus returned ZERO mentions of plugin auto-restart against 118 control hits for "health" and 13 for "hot reload" in the same corpus. The other two keys leave with the first rather than as a tidy-up: with no restart, "Maximum restart attempts before giving up" and "Backoff strategy for restart delays" have nothing left to be the vocabulary OF — the same test that took distributedConfig out with the stateStrategy value it was documented as being required for (ruled 2026-08-26: a vocabulary of nothing is not a vocabulary). All three are TOMBSTONED rather than deleted, for the reason the file-watching retirement recorded: a key leaving a SURVIVING def has no route-3 exit, and PluginHealthCheckSchema is not .strict(), so a bare deletion would be a silent strip (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — a milder form of the very defect being retired. There is no D2 conversion, because PluginHealthCheck is not an authorable surface: no metadata-type binding, stack collection or manifest embed ever carried it, so there is no authored document to rewrite. This entry IS the declaration.
    • Done when: No host passes autoRestart, maxRestartAttempts or restartBackoff to PluginHealthMonitor.registerPlugin. TypeScript hosts cannot: all three are typed never by the tombstones. JavaScript hosts, and config that arrived as JSON, get a loud refusal carrying the prescription — an ADR-0112 envelope (code: VALIDATION_ERROR, status: 400), thrown BEFORE any state is stored so a refused config cannot leave a half-registered plugin behind. PluginHealthMonitor no longer calls plugin.destroy() at all: attemptRestart and calculateBackoff are gone with the restartAttempts counter, and a plugin that crosses failureThreshold is reported degraded / unhealthy / failed and left running. The rest of the monitor is UNCHANGED: registration, periodic checks, the timeout race and its guard timer (kept ref'd while the race is undecided, cleared the moment it settles), the two failure routes — a returned failure and a thrown or timed-out check — sharing one failure counter and one threshold comparison, and successThreshold binding from every status that records a failure all behave exactly as before — recovering is now written only by the success branch, which is the one writer that ever meant it. The maintainer's 2026-08-25 keep of the host-driven library still stands: PluginHealthCheckSchema still exports from ./kernel and PluginHealthMonitor still exports from @objectstack/core with its tests green.
  • plugin-manifest-contributes-dead-members-retired — manifest.contributes.events / manifest.contributes.menus / manifest.contributes.themes / manifest.contributes.translations / manifest.contributes.actions / manifest.contributes.drivers / manifest.contributes.fieldTypes / manifest.contributes.functions / manifest.contributes.commands (nine of the block's eleven members; kindsandroutes are NOT part of this retirement) → delete the keys — each capability already has its one enforced channel: events → subscribe imperatively in plugin code (ctx.hook('kernel:ready', …) from init/start); menus → the app navigation tree or manifest.navigationContributions (ADR-0029 D7); themes → the stack-level themes metadata collection (an unrelated ThemeSchema surface); translations → the translation metadata type, authored with defineTranslationBundle in defineStack({ translations }); actions → the stack actions collection or engine.registerAction; drivers → register a kernel service named driver.* (objectql calls registerDriver on it); fieldTypes → nothing (no registration seam exists; the vocabulary is the spec FieldType enum); functions → defineStack({ functions }) → engine.registerFunction; commands → oclif native plugin auto-discovery (an oclif section in the plugin's own package.json; see cli-extension.zod.ts)
    • Why not automatic: ADR-0049 enforce-or-remove: the nine members retire together, once the cloud half of the census below had come back clean. A census, monorepo-wide and non-test with control probes, measured that the ENTIRE monorepo contains exactly one read of manifest.contributes — packages/objectql/src/engine.ts, member kinds — so all nine members above parsed, entered the manifest, and changed nothing. The census stands on three repos: objectstack (re-verified on current main at claim time), objectui (0 property reads; control: 63 files carry the bare word), and cloud (measured clean 2026-08-24 at 5b5925a: zero manifest.contributes reads, controls held). Several members were actively misleading: events was authored in-repo by a plugin that already subscribes imperatively; commands documented Commander.js resolution the CLI dropped for oclif auto-discovery; fieldTypes advertised a registration seam that has never existed. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and PLURAL_TO_SINGULAR has no packages / plugins entry, so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the kernel/Manifest:loading precedent, recorded verbatim in its retired-key entry).
    • Done when: No objectstack.config.ts manifest and no packaged manifest.json authors any of the nine members. 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 per-key tombstone prescription; TypeScript authors fail earlier still (each key is typed never). contributes.kinds keeps parsing and registering (registry.registerKind), and contributes.routes is left to its own enforce-or-remove fork (since decided: retired, see plugin-manifest-contributes-routes-retired). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the nine members, so removing them removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single [metadata_spec_invalid] log line at registration (the registry's validate() is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling.
  • plugin-manifest-contributes-routes-retired — manifest.contributes.routes (the one member the nine-member retirement deliberately left to its own fork; kinds is now the block's sole surviving live member) → delete the key. A route that needs real handler CODE is mounted imperatively: resolve the http.server service from the plugin context and register the handler on kernel:ready (plugin-hono-server registers the service; examples/app-showcase mounts POST /api/v1/showcase/recalc that way). A declarative endpoint over a pipeline the platform already runs — query/return records, trigger a flow — is defineStack({ apis }) (live since protocol 17, once the declarative endpoint executor was built and the loud refusal of a non-empty apis: became execution)
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-22 (Option B of the enforce/remove/enforce-later fork, accepted verbatim 「接受所有」 on the decision batch carrying the four-axis analysis): remove the key, and redirect every author-facing recommendation of it to the imperative http.server mount. A monorepo-wide census with control probes measured zero readers of the key: the HttpDispatcher never registered a prefix from the declaration, so an entry parsed cleanly and served nothing — while FOUR published surfaces presented it as working machinery, one of them a customer-published skill (skills/objectstack-api told authors to choose it when "the endpoint needs real handler CODE"). That is ADR-0049's silent no-op with a published recommendation attached. Per the ruling's own sequencing the author-facing corrections landed FIRST (the skill's decision table, the dispatcher protocol doc, ADR-0088:40 and app.mdx, each redirected to the imperative mount), and the two remaining teaching sites (the plugin-rest-api.zod.ts worked manifest example, the metadata-plugin.zod.ts router delivered-form comments) are redirected in the removal PR itself. The cloud precondition was discharged first: a census of the cloud repository at 5b5925a found zero manifest.contributes reads, controls green. Enforce (fork A) was weighed and rejected on all four facets: net-new execution surface plus a prefix-claim authority question (who may claim /api/v1/…) for a declarative spelling with zero measured authors, while the capability is already reachable imperatively. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (PLURAL_TO_SINGULAR has no packages/plugins entry), so a conversion would be a transform with no seam that ever runs.
    • Done when: An authored contributes.routes is a loud rejection through every spec-validating path — retiredKey() types it never (tsc error at the authoring site) and the parse raises the prescription itself (os plugin build exits non-zero printing it). contributes.kinds — the block's sole surviving member — keeps parsing and registering (engine → registry.registerKind). No author-facing material still recommends the key: every former teaching site points at the imperative http.server mount (and defineStack({ apis }) for declarative projections). ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the member, so removing it removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single [metadata_spec_invalid] log line at registration (the registry's validate() is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling.
  • plugin-manifest-dead-containers-retired — manifest.capabilities / manifest.configuration / manifest.extensions (three top-level containers; retiring the container settles every key beneath it — capabilities.{implements,provides,requires,extensionPoints,extensions}andconfiguration.{title,properties} — at once) → delete the keys — each declared purpose either has its one enforced channel or never existed: configuration (a { title, properties } settings surface no UI rendered and no loader resolved) → pass options to the plugin's constructor in defineStack({ plugins: [new MyPlugin({ … })] }), the channel hosts already use; capabilities (protocol/interface declarations sold as "interoperability and automatic discovery") → nothing — no discovery path ever existed; real dependency resolution runs off top-level manifest.dependencies, which stays; extensions (an untyped z.record(z.string(), z.unknown()) catch-all) → the enforced extension channels: contributes.kinds registers metadata kinds, navigationContributions (ADR-0029 D7) injects navigation, and code-level extension lives in the plugin itself (init/start)
    • Why not automatic: ADR-0049 enforce-or-remove, dispatched once the cloud half of the census below came back clean. A census, monorepo-wide and non-test with control probes, measured ZERO reads of each container itself, which settles all eight keys beneath them — a key cannot be read if the object holding it never is. The census stands on three repos: objectstack (re-verified on current main at claim time; every bare .capabilities hit classifies to a different surface — driver loader contracts, the QuickJS sandbox argument set, REST discovery, the ADR-0066 stack-level capabilities collection), objectui (0 container reads; control: manifest.(id|name|namespace|version) reads findable), and cloud (measured clean 2026-08-29 at 15f55df: zero reads of all three, controls positive). configuration.properties.secret made this false compliance rather than tidying: its describe() promised "value is encrypted/masked (e.g. API Keys)" and nothing ever encrypted, masked or parsed it, so the key's own text was an unkept assurance about credential handling. Why D3 semantic and not a D2 conversion: the conversion chain walks a normalized STACK and PLURAL_TO_SINGULAR has no packages / plugins entry (re-verified — its capabilities entry is the unrelated ADR-0066 stack collection), so a manifest is not a stack collection member and a conversion would be a transform with no seam that ever runs (the kernel/Manifest:loading precedent, recorded verbatim in its retired-key entry). PluginCapabilityManifestSchema stays published: the plugin-registry surface (plugin-registry.zod.ts) still declares it, so this is a carrier-key tombstone with no def removal.
    • Done when: No objectstack.config.ts manifest and no packaged manifest.json authors any of the three containers (the two in-repo authors — driver-memory and plugin-hono-server, both writing configuration and capabilities blocks nothing read — were cleaned with this retirement). 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 per-key tombstone prescription; TypeScript authors fail earlier still (each key is typed never). Live neighbours are untouched and must be verified as such: manifest.dependencies keeps resolving dependencies, contributes.kinds keeps registering, navigationContributions keeps merging. ⚠️ Runtime behaviour is deliberately UNCHANGED: nothing ever read the three containers, so removing them removes no behaviour. A package ALREADY INSTALLED whose stored manifest carries one degrades to a single [metadata_spec_invalid] log line at registration (the registry's validate() is a diagnostic, not a gate) rather than a boot failure; clear it by deleting the key from the source manifest and reinstalling.
  • plugin-manifest-kind-globs-retired — manifest.contributes.kinds[].globs (the kindbucket itself and itsid are untouched) → delete the key — a kind entry is { id, description? }. File-type discovery is single-channel on the metadata type registry's filePatterns (MetadataTypeSchema, registered via registerMetadataTypeSchema / the default registry), which contributes.kinds never extended; if plugin-extensible discovery is ever wanted, it gets designed against that registry, not revived here
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-24 (「接受你的建议。」) on the aligned four-facet analysis: remove, through the full ADR-0049 ceremony. The sub-field was declared-but-unenforced on an authorable published surface: the schema promised that declaring globs "enables the system to parse and validate new file types" (its own example: a BI plugin handling *.report.ts), and the platform accepted it, stored it, and served it back through GET /metadata/kind — while the discovery the description promised never ran, because real glob-driven artifact discovery reads filePatterns off the metadata type registry and metadata-plugin.zod.ts records outright that contributes.kinds does not extend it. Measured by the engine-lane fix that made kind registration log its declared id (which also found the kind bucket itself reachable through GET /metadata/:type), and re-verified at claim time with a positive control: zero value reads anywhere (the only non-test occurrences of the path are the schema declaration and two type positions), and no in-repo manifest authors the key outside test fixtures. Enforce was weighed and rejected on all four facets: it would build a SECOND discovery channel parallel to filePatterns for a spelling with zero pull. Why D3 semantic and not a D2 conversion: a manifest is not a stack collection member (PLURAL_TO_SINGULAR has no packages/plugins entry), so a conversion would be a transform with no seam that ever runs.
    • Done when: An authored contributes.kinds[].globs is a loud rejection through every spec-validating path — retiredKey() types it never (tsc error at the authoring site) and the parse raises the prescription itself (os plugin build exits non-zero printing it). contributes.kinds with { id, description? } still parses and still registers (engine → registry.registerKind), and the registered bucket stays reachable via GET /metadata/kind. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing read the value, so removing it removes no behaviour; the registerKind / getAllKinds type positions drop globs from their declared shapes (a type-only change — the parameter widens). A stored kind item that still carries globs keeps serving as stored data; clear it by deleting the key from the source manifest and republishing.
  • plugin-security-scan-result-surface-retired — the plugin-security scan-result family: the defs KernelSecurityScanResult and KernelSecurityVulnerability (kernel/plugin-security-advanced.zod.ts), their two authorable carriers on PluginSecurityManifest — scanResults and vulnerabilities — and the sibling verdict block PluginQualityMetrics.securityScan (kernel/plugin-registry.zod.ts) → nothing to re-declare — delete the keys and every import of the two types. Plugin security scanning is not a platform capability and there is no replacement schema. What the platform does still enforce, and what to reach for instead: permissions and sandbox on the same PluginSecurityManifest are unchanged, and artifact provenance is answered by verifyPluginArtifactIntegrity and the plugin signature verifier — which tell you an artifact is the one its publisher signed, and never that it is safe. For dependency vulnerabilities use the tools built for it against your own project (npm audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV) and treat an unaudited third-party plugin as untrusted code. A publisher who used scanResults to advertise diligence keeps the surviving securityContact and vulnerabilityDisclosure blocks, which are contact terms rather than a verdict.
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-07 (adopted verbatim 「同意」): retire the scan-result family and its securityScan sibling, because once the scanner was gone nothing so much as imported their types. This is the second half of the scanner retirement recorded as plugin-security-scanner-retired. That retirement removed PluginSecurityScanner — a @objectstack/core class that shipped as a SECURITY control and could not fail, whose verdict was status "passed" for every plugin it was ever handed. The SCHEMAS the scanner fed survived it, and the scanner had been their only importer of any kind (a type-only import in packages/core/src/security/security-scanner.ts), so the family went from one type-only importer to zero consumers while staying fully published: 27 authorable rows across kernel.json, six api-surface exports, two authorable defaults and two json-schema manifest keys. An author could write any of it, be accepted, and get nothing — declared-not-enforced, Prime Directive #10, one layer out from the class removed for the same reason. The census was taken on origin/main after that removal landed, with a lit control (five hits for PluginSecurityManifest inside the declaring module) proving the file greppable, and found no .parse or .safeParse site against either schema anywhere in packages/**. securityScan is the sharpest member: scanResults published a report, but securityScan.passed published a VERDICT, so a plugin could declare itself clean with nothing behind it. Route: the two defs leave the build whole (RETIRED_DEFS_BY_MAJOR[18]) because nothing parses them and a prescription nobody can receive is not worth its cost; the three authorable keys are retiredKey() tombstones (RETIRED_KEYS_BY_MAJOR[18]) because both carrying shapes are non-strict, where a bare deletion is a silent strip (ADR-0104). Why this entry and not a D2 conversion: a plugin security manifest and a plugin registry entry are package artifacts a publisher ships, never stack collection members and never stored sys_metadata rows, so the conversion chain has no seam that would see one — the disposition the sibling kernel-plugin-security-durations-unit-in-key entry already records for this same manifest. No deprecation window (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」). Scope note, recorded rather than acted on: PluginSecurityManifest.vulnerabilities is a forced consequence rather than a name the ruling listed — it was the last authorable referent of KernelSecurityVulnerability and could not outlive the def. Two neighbours the ruling made CONDITIONAL are deliberately untouched here because the repository the condition names, objectstack-ai/cloud, is not reachable from the session that executed this: the marketplace "scanning" status stays exactly as it is — unremoved, and NOT recorded as checked. Its two siblings were MEASURED rather than assumed, and the record is corrected here: both were ALREADY GONE when that ruling was written. The incident "malware" type was a member of system/IncidentCategory, and the whole incident-response family was retired whole, with the training and change-management families (maintainer ruling 2026-09-05: not roadmapped, so retired rather than marked experimental — two days BEFORE the 2026-09-07 ruling that made it conditional); see incident-response-family-retired. And marketplace-admin.zod.ts was deleted outright with the cloud subpath (ruled 2026-09-07: cloud does not re-host the control-plane files it never consumed); see cloud-subpath-retired. Verified on this tree by shape: both files return zero tree entries and no *.zod.ts names malware at all, against a lit control where "scanning" still returns a live declaration in marketplace.zod.ts. So the conditional question is ONE enum member wide, not three, and the objectstack-ai/cloud producer grep it still owes is that much smaller. ⚠️ The out-of-repo consumer population is NOT MEASURED. @objectstack/spec is published, so this removal is breaking for consumers no download, dependent or source telemetry was consulted for — accepted as an input to the ruling, exactly as that retirement states of its own three exports, and not a reason to soften the removal. ADR-0049, ADR-0087.
    • Done when: No source imports KernelSecurityScanResult, KernelSecurityVulnerability or either Schema from @objectstack/spec/kernel: both defs are absent from the built kernel barrel and from api-surface/kernel.json, so a TypeScript consumer gets the refusal at compile time at the import site rather than a missing runtime value. Authoring PluginSecurityManifest.scanResults, PluginSecurityManifest.vulnerabilities or PluginQualityMetrics.securityScan fails to compile (input type never) and fails to parse with the tombstone prescription naming that key — verified by refusal pins that assert the issue code, the path naming WHICH key was refused, and the prescription text, plus a positive pin that the surrounding manifest still parses and grows no such property. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read any of these keys, so deleting one removes no check that was running. A publisher who believed a declared scanResults entry gated anything was never getting that gate; the remediation is to audit with a real tool, not to find a replacement key. The surviving neighbours must still parse and still be exported — permissions, sandbox, policy, codeSigning, certifications, securityContact and vulnerabilityDisclosure on the manifest, testCoverage/documentationScore/codeQuality/conformanceTests on the quality metrics, and the separately-declared SecurityScanResultSchema / SecurityVulnerabilitySchema in kernel/plugin-security.zod.ts, which this change does not touch.
  • plugin-security-scanner-retired — @objectstack/core` runtime exports: `PluginSecurityScanner`, and the two types declared only to feed it, `ScanTarget` and `SecurityIssue → nothing to re-declare — delete the import and every call. Plugin security scanning is not a platform capability and there is no replacement export. A caller that branched on result.status === "passed" takes that branch unconditionally, because it is the only branch the scanner ever produced. What the platform does still enforce, and what to reach for instead: artifact integrity and signatures (verifyPluginArtifactIntegrity, the plugin signature verifier) answer "is this the artifact the publisher signed?" and never "is this artifact safe?"; plugin permissions and the sandbox resource limits are unchanged. For dependency vulnerabilities use the tools built for it against your own project — npm audit / pnpm audit, Dependabot, the GitHub Advisory Database, OSV — and treat an unaudited third-party plugin as untrusted code.
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05: retire the class and its two companion types with no replacement export, rather than repair it. The class shipped on @objectstack/core's public barrel as a SECURITY control and could not fail. scan() composed five private scanners: four of them (scanCode, scanMalware, scanLicenses, scanConfiguration) allocated an empty issue array, logged and returned it with no code in between, so none could report a finding for any input; the fifth, scanDependencies, ran a real loop but matched only against an in-memory vulnerability database whose sole writer, the public addVulnerability, had zero callers in objectstack, in objectui at the pinned sha, or in the one demonstration that constructed the scanner, and updateVulnerabilityDatabase() logged twice and fetched nothing. The database was therefore empty on every code path that has ever executed: no issue was ever produced, the score stayed 100, and the verdict was status: "passed" for every plugin the scanner was ever handed — a malicious one as readily as a benign one. Repair was refused by name: a real vulnerability scanner is a feature with a design surface, not a defect fix. Why this entry exists at all, and why D3 semantic rather than a D2 conversion: PluginSecurityScanner has no spec schema and never had one — it is a runtime TS class, so there is no authorable key to tombstone with retiredKey(), no stored sys_metadata row that could carry it (a scanner was constructed per call and every result lived in a per-instance Map discarded with the object), and hence no seam applyConversionsToStoredItem would ever reach. The enforced channel is tsc, at the consumer's own import site; for anyone it does not reach, this ledger entry and the generated upgrade guide are the only channel there is. That is the disposition of contracts.IDataDriver.findStream (removed with no tombstone, because nothing parses a driver object) and of actor-user-roles-to-positions (the ctx.user roles alias, closed at once on the maintainer's word rather than given a window) — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface one layer further out than either: those are declared in packages/spec, this one only in packages/core. ⚠️ The out-of-repo consumer population is NOT MEASURED. Zero constructors were found in objectstack, in objectui at the pinned sha, and in the deleted example, but no download, dependent or source telemetry was consulted for consumers of the published package, so this is breaking for an unmeasured population rather than a removal proven to break nobody.
    • Done when: No source imports PluginSecurityScanner, ScanTarget or SecurityIssue from @objectstack/core (or from @objectstack/core/security, a subpath the package has never declared in its exports and which therefore resolved for nobody). A TypeScript consumer gets the refusal at compile time at the import site — the export is absent from the built dist/index.d.ts, not merely undocumented. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: every scan this class ever performed returned zero issues and status: "passed", so deleting a call removes no check that was running. A caller that treated a passing scan as evidence of safety was never getting any, and its remediation is to audit dependencies with a real tool, not to find a replacement symbol — there is none. Verified in-repo by export-list assertions on both barrels (packages/core/src/security/security-scanner-retirement.pin.test.ts), not by a grep: the name legitimately survives in the tombstone comments that explain the retirement.
  • plugin-version-semver-2-0-0 — plugin.version — PluginSchema.version (kernel/plugin.zod.ts), the key a plugin object carries into kernel.use(), and the boot-path predicate that judges the same string in @objectstack/core (plugin-loader.ts) → a SemVer 2.0.0 string matching SEMVER_2_0_0_VERSION_PATTERN (kernel/version-grammar.ts). ⭐ Only EIGHT strings stop loading, all of them forms the standard forbids: 01.1.1, 1.01.1, 1.1.01 (§2, a leading zero in a numeric identifier — drop it); 1.0.0-0123, 1.0.0-alpha..1, 1.0.0-alpha.., 1.0.0-. (§9, a prerelease identifier that is empty or carries a leading zero — name it, or remove the empty segment); 1.0.0+. (§10, an empty build identifier — name it or drop the + suffix). ⛔ Nothing else moves: every valid prerelease and build form this key accepts today it still accepts, 1.0.0-alpha.1 and 1.0.0-rc.1+exp.sha.5114f85 included.
    • Why not automatic: The canon ruling made one grammar serve every carrier of "the version of a package or plugin", and named it after the standard: SemVer 2.0.0. This key had the widest of the four accept sets, which is why it is the only one that narrows without also widening. The narrowing is bounded deliberately, and the bound is what keeps the earlier widen-never-narrow ruling on this path honoured rather than reversed: that ruling's subject is what LOADS, and none of the eight is a valid prerelease. What they have in common is that no precedence order exists for any of them — dependency-resolver.ts in @objectstack/core can place none of them in an order — so a plugin versioned this way could be published and never compared against its own successor, which is a worse outcome than the refusal. Why it is a D3 semantic TODO and not a D2 conversion: each of the eight has several defensible repairs and the metadata does not say which was meant, and a version is how a release is addressed — rewriting one silently re-points whatever already resolved the old string.
    • Done when: Every plugin you ship boots: kernel.use(plugin) resolves for each of them, on both ObjectKernel and LiteKernel. The only versions needing an edit are the eight forms above — a git grep for a leading zero in a numeric segment and for a doubled or trailing dot in a suffix finds them all, and the in-repo authoring corpus measured ZERO producers of any of them. For each one you change, confirm nothing still resolves the old string: no dependencies range in another manifest, no installed row, no lockfile pin. ⛔ Do not repair one by widening the check back — the grammar is the contract now, on nine carriers at once.
  • predicate-write-unreadable-row-not-matched — the data write doors — a predicate-scoped (multi) update or delete, on every object and for every principal → read a predicate update or delete as reaching only the rows the caller can read: the result counts those rows alone, a predicate that reaches only hidden rows succeeds with zero rows, and a predicate whose readable match exceeds one write's row ceiling is refused with 400 INVALID_FILTER — narrow it and write in batches
    • Why not automatic: A WRITE-DOOR ANSWER, made one with the read door's, on the predicate door as on the by-id door. The rows a predicate update or delete matched came from its write scope alone, so a row the caller cannot read was matched whenever that scope reached it: a per-row gate then refused the write with a 403, or the row was written and counted. Either answer told a hidden row apart from no row. The write middleware now asks the read door which rows the caller's own predicate returns — a read in the caller's context that every data middleware's visibility applies to — and narrows the matched set to them, so a row the caller cannot read is not written, not counted and not refused. A read the read door refuses keeps the write's previous answer, and a readable match larger than one predicate write's row ceiling is refused rather than cut off. A caller who can read a matched row but may not write it keeps its answer. Writes the platform issues under the caller's context — a cascade, a hook's own write, the referential clear of a lookup — keep their previous answer, and by-id writes are unchanged.
    • Done when: Every caller that issues a predicate update or delete reads its count as the rows it can see and no longer reads a 403 there as proof a hidden row matched; an operator who needs a user to change rows grants that user read access to them first; a predicate whose readable match exceeds the row ceiling is narrowed and written in batches.
  • qa-scenario-requires-plugins-retired — qa.scenarios[].requires.plugins → requires.services — the discovery service keys the scenario needs (for example auth, analytics, automation, ai), each judged against the target's discovery document: met only when the target declares the service enabled with status available. The plugin → service mapping follows the provider table discovery itself reports (CORE_SERVICE_PROVIDER): @objectstack/plugin-auth fills auth, @objectstack/service-analytics fills analytics, @objectstack/service-automation fills automation, and so on. A plugin that fills no discovery service slot has no service to require.
    • Why not automatic: requires.plugins was declared as a precondition and checked by nothing: os test reaches its target over HTTP, no served surface lists the loaded plugins, and the plugin spelling (package name or plugin.name) was never defined — so a scenario naming a missing plugin ran anyway and failed, or passed, on whatever the missing plugin caused. The block is now enforced (ADR-0049): core's TestRunner judges requires before the first step, and an unmet entry makes the scenario SKIPPED with a reason, counted separately and never as passed. plugins could not join that judgement honestly, so it retires into services, which the target's discovery document already answers (ADR-0076 D12: advertise only what is mounted). The consumer still owes the judgement because a plugin name does not always map to one service — a plugin that fills no discovery slot was never a checkable precondition, and only the suite's author knows what the scenario needed it for.
    • Done when: No QA suite (qa/*.test.json) carries requires.plugins — os test now refuses such a file at load time with the retirement prescription, naming the key, instead of running it; tsc refuses the key at a typed authoring site (never). Each scenario that declared plugins declares the services it needs in requires.services, and os test against a target that serves them runs the scenario, while against one that does not it prints the scenario as skipped with the unmet service and the services the target declares available. A suite without requires runs exactly as before.
  • realtime-event-type-unemitted-values-retired — api.RealtimeEventType — the values 'record.created', 'record.updated', 'record.deleted' and 'field.changed' left the enum. It types SubscriptionEvent.type, so it reaches Subscription.events[].type and RealtimeConfig.subscriptions[].events[].type → the names the runtime emits, which are now the whole enum: 'data.record.created' / 'data.record.updated' / 'data.record.deleted' for a single-record write, and 'data.records.updated' / 'data.records.deleted' for a predicate write (multi: true), which carries a count and no record. 'record.created' becomes 'data.record.created'; 'record.updated' and 'record.deleted' become their data.record twins, plus the data.records twin where a predicate write must be heard too; 'field.changed' becomes 'data.record.updated', whose DataEvent payload lists the changed fields in changes
    • Why not automatic: ADR-0049 enforce-or-remove. RealtimeEventType was published in the generated API reference as the vocabulary of a realtime subscription, and no producer anywhere emitted any of its four values. What the runtime publishes is the DataEventType / BulkDataEventType vocabulary: the ObjectQL engine sends data.record.created, data.record.updated and data.record.deleted for each written record and data.records.updated / data.records.deleted for a predicate write, and it parses every event through DataEventSchema / BulkDataEventSchema before publishing. A subscription written with the only names the reference showed could therefore never fire, and nothing said so. The direction was settled before this change: the enum moves to the emitted names, and the runtime keeps publishing exactly what it published — changing the runtime's live event names to match an enum nothing had ever used would break every real subscriber. field.changed is the same dead spelling that DataEventType already dropped in protocol 17 (the entry data-field-changed-event-retired): no per-field event exists, because an update's per-field detail rides on data.record.updated as changes. Metadata change events (metadata.{type}.{action}) were not added: a subscription event is record-shaped (object names a data object, filters narrows records), and metadata events have their own MetadataEventType contract and client primitive. Bookkeeping: an enum VALUE puts nothing in RETIRED_KEYS_BY_MAJOR and leaves the four surface ratchets untouched; its prescription hangs on the enum's own error map (the HookBodyCapability precedent). It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: stack.zod.ts has no realtime key, no metadata type holds a subscription, and the open framework mounts no realtime transport that would parse one (maintainer ruling of 2026-09-04: realtime stays out of open core) — so the conversion chain has no seam that would ever see a subscription. ADR-0049 / ADR-0087.
    • Done when: No code or document names 'record.created', 'record.updated', 'record.deleted' or 'field.changed' as a RealtimeEventType value. TypeScript rejects each one at a RealtimeEventType or SubscriptionEvent position, because the type no longer contains it, and a SubscriptionSchema, SubscriptionEventSchema or RealtimeConfigSchema parse refuses it with its per-value prescription (pinned in api/realtime.test.ts). A subscriber that meant field.changed listens on data.record.updated and reads the field from the DataEvent payload's changes map. A handler keyed on an old name never ran, since nothing emitted it, so renaming it changes behaviour only by making it fire.
  • record-chatter-position-vocabulary-converged — ``record:chatter/record:discussioncomponent props (one shared schema object):positionvocabulary, and the schema defaults onposition/collapsible/defaultCollapsed (DROPPED) → position: 'bottom' | 'right' | 'left' — the renderer's own vocabulary (right/left dock a side panel, bottom renders in flow). 'sidebar' → 'right', 'inline' → 'bottom', 'drawer' → 'right' (no overlay drawer ever existed). No key replaces the dropped schema defaults: an unset key now stays unset and the renderer's own fallbacks apply (position 'bottom', collapsible off, defaultCollapsed off)
    • Why not automatic: The schema declared a position vocabulary no read point ever compared (sidebar/inline/drawer), while the renderer chain — panel branches, designer registration, merge fallback, three sites in agreement, measured at objectui pin 665661ab0932 — speaks exactly bottom/right/left. So the spec-valid sidebar (the schema's own DEFAULT, materialized onto every parsed node that said nothing) silently fell through to the in-flow render, and the value that actually docks the panel (right) was refused at publish — declared ≠ enforced in both directions on the same key. The maintainer ruling of 2026-08-15 on this row converged it on the renderer's vocabulary with no mapping layer, and dropped all three schema defaults per the maxVisible principle (renderer fallbacks stay the renderer's facts): the old collapsible default (true) additionally INVERTED the renderer merge's own fallback (false), so "the author said nothing" parsed into "the author asked for collapsible". The mechanical rewrite is the ADR-0087 D2 conversion record-chatter-position-vocabulary (retired from the load path — the enum refuses the old spellings at parse with a per-value prescription; stored rows replay clean via the rehydration seam). This semantic entry exists for the two judgements the chain cannot make: whether drawer → right (a docked panel standing in for a never-implemented overlay) is the presentation the author wants, and whether a page that relied on the old materialized collapsible: true default should now author it explicitly. ADR-0087, maintainer ruling 2026-08-15.
    • Done when: No authored record:chatter / record:discussion component carries position: 'sidebar' | 'inline' | 'drawer'; objectstack validate passes. Review the rewritten values against intent: 'sidebar' and 'drawer' became 'right' (a docked side panel — what both spellings meant, but NOT what they did: both used to fall through to the in-flow render, so the page's visible layout changes to the docked panel the author originally asked for). Where the in-flow presentation was actually wanted, write 'bottom'. If a panel relied on the old schema default collapsible: true, author collapsible: true explicitly — an unset key now defers to the renderer, which does not collapse.
  • record-highlights-field-icon-retired — page.component.record:highlights.fields[].icon — the per-chip icon on an object entry of the highlights field list → (removed — the highlight chip has no icon slot.) Carry whatever the icon was meant to signal in what the chip does render: its label, or the field's own value.
    • Why not automatic: The D2 conversion record-highlights-field-icon-removed deletes icon from the object entries of every record:highlights field list, and the delete is lossless: the chip renders a label and a value and nothing else, the registration path carries field names only, and the Studio designer publishes the list as plain strings, so an authored icon was accepted and drawn by nothing. The residue is the author's intent. Six author-facing surfaces advertised the key, so an author may have chosen an icon to carry meaning — a warning glyph beside a risk score, a flag beside a region — and designed the page assuming a reader would see it. That meaning was never shown and is not shown now; only the author can say whether it matters and where it should live instead.
    • Done when: No record:highlights field entry carries icon; the parse refuses it. The highlights strip renders the same chips, in the same order, with the same labels and values as before the upgrade. For each chip whose icon carried meaning, a reader who sees only the label and value can still tell what the icon was meant to say. Any tooling that generated highlight entries (a code generator, a template) no longer emits the key.
  • rest-api-config-dead-keys-retired — restServer.api.responseFormat / restServer.api.documentation.enabled → (removed — delete each key; neither had an effect to preserve. Whether the server publishes its OpenAPI document and the docs viewer is api.enableOpenApi, the switch the mount already reads. Response shapes are fixed — each route answers in the response schema @objectstack/spec/api declares for it — and are not a server-wide option, so there is no replacement for responseFormat.)
    • Why not automatic: The rest_api liveness census found every member of these two keys dead: normalizeConfig parsed them, applied their defaults and copied them into the REST server's config, and no site ever read them back. So responseFormat.envelope: false unwrapped no response, includeMetadata and includePagination gated nothing, and documentation.enabled: false turned no document off — the document's existence was, and is, decided by api.enableOpenApi at the mount. Enforce-or-remove (ADR-0049) resolved both to REMOVE: mainstream data APIs keep a fixed response envelope that no administrator toggles server-wide, a configurable envelope would fork the declared response shapes the client SDK parses and the served /openapi.json describes, and documentation.enabled duplicates a switch that is already enforced. RestApiConfigSchema and its inline documentation block are non-strict z.object()s, so each key is a retiredKey() tombstone and its ledger row stays dead with a REMOVED note. No stored or built artifact carries either key, so no emitted default needs to be tolerated as residue: the config is a construction argument that is parsed and consumed in the same process. The consumer still owes the judgment because a host that WROTE envelope: false or documentation.enabled: false believed its clients saw a different shape or no document, and only that host knows which clients were built on the belief.
    • Done when: No RestServerConfig value passed to the REST plugin carries api.responseFormat or api.documentation.enabled — a config that does now fails RestServer construction (and so the REST plugin's start) with the retirement prescription, naming the key and RestApiConfigSchema, instead of being accepted and ignored; tsc refuses the key at the authoring site (never). A host that meant "serve no OpenAPI document" sets api.enableOpenApi: false and sees GET /openapi.json and GET /docs unmounted. Every client that parses REST responses reads each route's declared response shape. Every LIVE key of the api block — including documentation's other members — parses byte-identically to before, and the mounted REST surface is unchanged: neither key ever reached it.
  • rest-api-documentation-version-retired — restServer.api.documentation.version → (removed — delete the key. The served OpenAPI document's info.version is the protocol version, i.e. the version of the @objectstack/spec package that generated the document, with no configured override. An app that wants to publish its own release number writes it into api.documentation.description, which the served info.description now carries.)
    • Why not automatic: The rest_api liveness census found documentation.version dead: normalizeConfig parsed it and copied it into the REST server's config, and no site read it back, so version: '2.3.0' never reached the served document. Enforce-or-remove (ADR-0049) split the documentation block by who owns each field. The title, description, terms of service, contact and license are the publisher's identity and are now enforced. info.version is a fact of the protocol: an earlier ruling made the served info.version equal the published artifact's, so an integrator can read which protocol version they are talking to, and it removed the serve-time override that had made the field mean the route identifier. A publisher-set version would give the field a third meaning, so the key is retired instead of enforced. RestApiConfigSchema's inline documentation block is a non-strict z.object(), so the key is a retiredKey() tombstone and its ledger row stays dead with a REMOVED note. The consumer still owes the judgment because a host that WROTE documentation.version believed its integrators read that number from the document, and only that host knows whether any client was built on the belief and where the number should be published instead.
    • Done when: No RestServerConfig value passed to the REST plugin carries api.documentation.version — a config that does now fails RestServer construction (and so the REST plugin's start) with the retirement prescription, naming the key and RestApiConfigSchema, instead of being accepted and ignored; tsc refuses the key at the authoring site (never). GET {apiPath}/openapi.json and its environment-scoped twin serve info.version equal to the one the bundled @objectstack/spec/openapi.json carries, whatever the config says. A release number the host still wants published appears in the served info.description after it is written into api.documentation.description.
  • rest-api-endpoint-handler-status-retired — RestApiEndpoint.handlerStatus (the implemented / stub / planned marker an endpoint in a REST API plugin route registration could carry), the HandlerStatusSchema / HandlerStatus value def it was typed with, and the RouteCoverageEntrySchema / RouteCoverageReportSchema report shapes (with their RouteCoverageEntry / RouteCoverageReport types) that re-declared it → nothing declarative — the key never changed what the platform served, so there is no working configuration to migrate to. Delete the key; an endpoint that has no handler yet is simply not registered. Route readiness that IS measured is unchanged and lives elsewhere: the discovery payload reports each service's status and handlerReady (api/discovery.zod.ts), and packages/runtime/src/route-ledger.ts asserts per-route coverage in CI. A declared-but-unbuilt route answering 501 instead of 404 is a new capability the ruling explicitly excluded (zero pull); if it is ever wanted it re-declares fresh under its own ruling, executor first
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling of 2026-09-01 on this key: remove it with a tombstone; enforce excluded. The key was DOCUMENTED to cause a specific runtime behaviour — its docstring said a stub handler "returns 501 Not Implemented" — and that behaviour has a different cause: every DispatcherErrorCode.enum.NOT_IMPLEMENTED site (runtime/src/endpoint-executor.ts ×3, runtime/src/api-mapping.ts, runtime/src/api-endpoint-step.ts) is the declarative-endpoint executor refusing a target or mapping it cannot serve, and none of them consults handlerStatus. Measured at the retirement base (origin/main a9b2be0b0, 2026-09-02, skills/** and tests excluded): the only identifier hits were the declaration on RestApiEndpointSchema, the re-declaration on RouteCoverageEntrySchema and a docblock saying adapters SHOULD warn on it; RouteCoverageReportSchema — the one shape that would have carried the status outward — had zero constructors in objectstack, objectui (pinned sha) and cloud. So an author who wrote handlerStatus: 'stub' expecting the dispatcher to answer 501 got an ordinarily served route, and the declaration reported progress to nobody — a declared ≠ enforced gap on the same endpoint vocabulary whose ApiEndpointSchema had already been closed strictly once api became a registered metadata type, and the surface a published skill had been teaching as working machinery (this finding came out of correcting that skill sentence, in a factual sweep of the API skill). Bookkeeping: the KEY is tombstoned with retiredKey() on the non-strict RestApiEndpointSchema (api/RestApiEndpoint:handlerStatus in RETIRED_KEYS_BY_MAJOR[18]); the DEFS leave whole — api/HandlerStatus (orphan value enum once both carriers are gone — an exported value schema with no consumer reads as a capability, so it leaves with its key), api/RouteCoverageEntry and api/RouteCoverageReport (route 3: nobody ever parsed or constructed one) — all three in RETIRED_DEFS_BY_MAJOR[18]. It is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite: nothing in the tree parses RestApiEndpointSchema outside its own unit tests — a REST API plugin route registration is not a stack collection member and never a sys_metadata row — so the conversion chain has no seam that would ever see one (the kernel/Manifest:loading disposition). ENFORCE was excluded by the ruling: mounting a 501 stub for stub / planned endpoints is a zero-pull new capability, not a repair. The same ruling records the class direction for two sibling ADR-0049 findings (the unbound branded identifier schemas and the event-name schema no runtime reads; not ruled by it): a declared-but-unenforced key with no pull retires; enforce/bind only on a named consumer or measured pull. ADR-0049 / ADR-0087.
    • Done when: No source writes handlerStatus on a RestApiEndpoint: authoring it is now a tsc error at the site (the tombstone types the key never) and a parse error carrying the prescription at path handlerStatus, for every former value including the documented default 'implemented' (which was prose only — the key never carried a Zod .default(), so no built artifact materialised it and there is no residue window). Pinned in api/plugin-rest-api.handler-status-retirement.test.ts. Concretely, check two places. (1) Every RestApiEndpoint literal — in a route registration passed to the REST API plugin, or standalone: delete the handlerStatus line; nothing served changes, because nothing ever read it. (2) Code importing HandlerStatusSchema, HandlerStatus, RouteCoverageEntrySchema, RouteCoverageEntry, RouteCoverageReportSchema or RouteCoverageReport from @objectstack/spec or @objectstack/spec/api: every one is TS2305 after upgrade; no replacement exists to point at, because no producer ever emitted the report. Everything else on RestApiEndpointSchema — method, path, handler, category, public, permissions, the OpenAPI and performance keys — parses exactly as before, and the shipped default route registrations (getDefaultRouteRegistrations) never carried the key and still parse.
  • rest-api-plugin-durations-unit-in-key — the three REST-plugin durations whose name carried no unit: RestApiEndpoint.timeout, RestApiEndpoint.cacheTtl and RestApiPluginConfig.performance.defaultCacheTtl (api/plugin-rest-api.zod.ts) → timeoutMs (milliseconds), cacheTtlSeconds (seconds) and defaultCacheTtlSeconds (seconds, default 300) — rename each key; every value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. RestApiEndpoint is this rule's clearest specimen after the founding one: timeout in MILLISECONDS and cacheTtl in SECONDS sat three lines apart on one shape, each unit named only in its describe, so the two numbers were indistinguishable at the authoring site and a value copied from one to the other was off by 1000x with no error anywhere. performance.defaultCacheTtl travels with them rather than in its own entry because it is the plugin-wide DEFAULT behind the per-endpoint override: renaming the override and leaving the default bare would have spelled one value two ways across one config. All three are retiredKey() tombstones — these shapes are not strict, so a bare deletion would strip the old key in silence, and defaultCacheTtl is a tombstone INSIDE the live performance block, whose siblings must keep parsing. Why a semantic entry and not a D2 conversion: a RestApiPluginConfig is the REST plugin's construction argument and a RestApiEndpoint is a route registration inside it — neither is a stack collection member or a stored row, so the chain has no seam that ever runs on them. That is the disposition api/RestApiEndpoint:handlerStatus already carries on this very shape (rest-api-endpoint-handler-status-retired), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087.
    • Done when: Every RestApiEndpointSchema.parse(…) and RestApiPluginConfigSchema.parse(…) site spells timeoutMs, cacheTtlSeconds and performance.defaultCacheTtlSeconds; authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription naming the suffixed key. The built-in route tables shipped from this module (DEFAULT_METADATA_ROUTES, DEFAULT_BATCH_ROUTES, DEFAULT_I18N_ROUTES, DEFAULT_ANALYTICS_ROUTES, DEFAULT_AUTOMATION_ROUTES, DEFAULT_DISCOVERY_ROUTES) author the new spellings at the same magnitudes they authored the old ones — a batch endpoint still gets 60000 ms and a discovery response is still cached for 3600 s.
  • rest-server-config-dead-keys-retired — restServer.crud.patterns / restServer.crud.objectParamStyle / restServer.metadata.cacheTtl / restServer.metadata.endpoints.schema / restServer.batch.operations.upsertMany / restServer.batch.defaultAtomic / restServer.routes.includeObjects / restServer.routes.excludeObjects / restServer.routes.nameTransform / restServer.routes.overrides → (removed — delete each key; none had an effect to preserve. Per-object API exposure is declared on the object: enable.apiEnabled: false hides it from the REST data surface (404) and enable.apiMethods whitelists its operations (405). The data base path is crud.dataPrefix, deployment-wide. An endpoint on a custom path or method — or one that needs its own summary / description / cacheTtl — is a declarative api endpoint (type: 'object_operation'). Batch atomicity is the per-request options.atomic (ADR-0119 D4); upsert is an operation type of the generic POST /data/:object/batch endpoint, gated by batch.enableBatchEndpoint.)
    • Why not automatic: The liveness census that enrolled the four RestServerConfig sub-objects found 15 of their 32 rows dead: parsed, defaulted and normalized into the REST server's config by normalizeConfig (which parses them, rather than casting them, since an earlier fix) and never read back. crud.patterns and routes.overrides described route customization the server mounts from fixed pairs; routes.includeObjects / excludeObjects and overrides.enabled / operations duplicated the object's own enforced exposure keys; nameTransform and objectParamStyle were enums validated and then ignored; metadata.endpoints.schema and batch.operations.upsertMany gated routes that were never built; metadata.cacheTtl fed no cache and no header; batch.defaultAtomic would have silently overridden a per-request contract ADR-0119 D4 had deliberately set. Enforce-or-remove (ADR-0049) resolved every family to REMOVE because each promised capability either already exists at its proper seat (the object, the declarative endpoint, the batch request) or would contradict a fixed contract (the client SDK, the discovery document and the served /openapi.json all describe the mounted CRUD paths; the object name is the REST path segment). All four schemas are non-strict z.object()s, so each key is a retiredKey() tombstone and its ledger row stays dead with a REMOVED note; api/CrudEndpointPattern, the value def of crud.patterns, leaves with it. No D2 conversion: a RestServerConfig is plugin TS configuration, never a stack collection member or a sys_metadata row (the openApi31 precedent). A closed-set sweep of the cloud repository at 9b6abe0f2fd5: zero hits, structural — cloud never authors a RestServerConfig.
    • Done when: No RestServerConfig value passed to the REST plugin (or plugin-hono-server restConfig) carries any of the ten keys — a config that does now fails new RestServer(...) / createRestApiPlugin().start() with the retirement prescription (naming the sub-object, the key and the declaring schema) instead of being accepted and ignored; tsc refuses the key at the authoring site (never). Every LIVE key of the four sub-objects parses byte-identically to before: crud.operations.*, crud.dataPrefix, metadata.prefix / enableCache / maskObjectFields / endpoints.types|items|item, batch.maxBatchSize / enableBatchEndpoint / operations.createMany|updateMany|deleteMany keep their defaults and their mounts. The mounted REST surface is byte-identical before and after — none of the ten keys ever reached it. No code imports CrudEndpointPattern(Schema) from @objectstack/spec/api (TS2305 after upgrade).
  • rls-check-on-select-or-delete-policy-refused — security.PermissionSet rowLevelSecurity[].check (RowLevelSecurityPolicySchema) on a policy whose operation is select or delete. A blank check (empty or whitespace only) declares nothing and is not refused → what the predicate was meant to guard, written where it runs. To limit which rows a select policy lets a caller read, or which rows a delete policy lets a caller delete, write the predicate as using on that policy (remove check; if the policy already has a using, AND the two with &&). To validate rows as they are written, declare the check on a policy whose operation is insert, update or all instead. The refusal lands at rowLevelSecurity[N].check, names the operation, and states both rewrites
    • Why not automatic: ADR-0049 enforce-or-remove and ADR-0058 D4. A check judges the post-image of a write: the new row of an insert, the changed row of an update. A select or delete writes no row, and the plugin-security write gate collects only the policies whose operation is the write's own or all, so a check on a select or delete policy was accepted, stored and never evaluated. Measured on main before this change: a policy carrying only check record.status != 'archived' on select or delete admitted every insert and update of an archived row, and beside a USING-only all sibling it did not replace that sibling's using default the way a check on an insert, update or all policy does. An author (an AI author above all) who wrote a check on a delete policy believed deletes were guarded by it. The refusal is a non-transforming refinement on the policy schema, so it reaches every door that parses a permission set: defineStack, os validate, and the metadata save path, whose permission type validates against PermissionSetSchema. Metadata AT REST is not rewritten and this entry adds no D2 conversion: dropping the key would silently discard the predicate the author wrote, and moving it to using would start filtering reads or deletes the policy never filtered before — both change which rows the policy admits, which is the policy author's decision. Ships at once, no transition window and no advisory lint phase.
    • Done when: Search every authored and stored permission set for a rowLevelSecurity policy whose operation is select or delete and whose check is non-blank — metadata files, sys_metadata permission rows, and the row_level_security column of sys_permission_set. For authored metadata the sweep is mechanical: PermissionSetSchema.safeParse answers one custom issue at rowLevelSecurity[N].check whose message begins "check is never evaluated on a select policy" (or delete). For each, decide what the predicate was meant to guard: reads or deletes ⇒ express it in that policy's using; writes ⇒ move it to an insert, update or all policy. Then re-check the policy set's behaviour rather than assuming it is unchanged: the removed check never ran, so dropping it changes nothing, but a predicate moved into using now filters rows it never filtered, and a check moved onto an insert, update or all policy now replaces the using default of its USING-only siblings for that write. A policy with using only, and a check on an insert, update or all policy, parse exactly as before. The repo, its example apps and the pinned console carried no such policy at the time of the change; two test fixtures that used select incidentally were moved to all and insert.
  • rls-predicate-array-comparand-refused — security.PermissionSet rowLevelSecurity[].check — a CEL predicate comparing a field with != or == against a list, a list literal or a current_user membership array, and the negation of such an ==. They lowered to { field: { $ne: [...] } }, { field: [...] } and { $not: { field: [...] } }, which the @objectstack/formula evaluator matchesFilterCondition now refuses, together with { field: { $eq: [...] } }, at any depth under $and / $or / $not, the empty array included → the list operator the comparison was standing in for. "One of these values" is in: record.status in ["open", "pending"]. "None of these values" is the negated in: !(record.status in ["closed", "archived"]). Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before
    • Why not automatic: Ruling A of 2026-09-24 refuses an array comparand under $ne, and ruling 乙 of 2026-09-23 refuses one in the implicit-equality slot, each for every driver at once; this change lands both on the formula face, the evaluator plugin-security runs against the post-image of an insert or update to enforce a row-level check. It compared strictly, and no stored value ever equals an array, so a check written record.status != ["closed", "archived"], or != against a current_user membership array, matched EVERY post-image, and a check written !(record.status == ["closed", "archived"]) did the same: every write such a policy was written to refuse was admitted and stored. The positive record.status == ["open", "pending"] refused every write (403). The evaluator now refuses all of these shapes before any record is judged. The message withholds the field, the operator and the value. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which list operator a list comparison was standing in for, and a policy rewritten on the author's behalf would change which writes it admits (the negated forms would start refusing writes they admitted, the positive form would start admitting writes it refused), which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112.
    • Done when: Grep the rowLevelSecurity check and using predicates of your permission sets for != or == whose right-hand side is a list literal or a current_user membership array, and for the negation of such an ==, then rewrite each with in or !(... in ...). Then re-check what each policy is supposed to refuse rather than assuming the writes it admitted before were right: before this change a != or a negated == against a list admitted every write.
  • rls-predicate-cross-class-field-comparison-refused — security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition — a CEL predicate comparing a field with another field (==, !=, >, >=, <, <=) where the two declared columns share no comparison class: text against a number, a date against a datetime, a boolean against text, and any column against a file field (file, image, avatar, video, audio) or a formula field. In a filter passed to matchesFilterCondition together with the object's declared columns (options.fields), a { $field } comparison under $eq, $ne, $gt, $gte, $lt or $lte between two such columns, and between a column and a json or multiple field → a comparison between two columns of one comparison class: a number with a number (number, currency, percent, rating, slider, progress, summary), text with text (the string types, autonumber, a single select or radio, a single lookup or user, a master_detail or a tree), a boolean with a boolean, a date with a date, a datetime with a datetime, a time of day with a time of day. A file field and a formula field cannot be compared with another column at all: compare the field with a literal or test it for null. If the two columns do hold comparable values, one of them is declared with the wrong type, so correct that declaration rather than the predicate. Comparisons between two columns of one class lower and evaluate exactly as before
    • Why not automatic: A column-to-column comparison has one meaning only within one comparison class: across classes SQLite orders every TEXT above every INTEGER while the in-process evaluator coerces ("open" > 5 is false). A formula field is virtual, with no stored column to reference. The file family is refused by name, whatever the deployment stores: during the ADR-0104 dual-encoding window one media column can hold a bare id and another the JSON-quoted form of the same id, so no comparison against the family is provably one answer on every path. driver-sql has refused such a comparison on the read since it first compiled a { $field } reference to a column-to-column comparison (a text column ordered against a number answered differently on SQLite than in memory, so the pushdown refused it), so a policy written record.status != record.amount (text and a number), record.status != record.photo (text and an image) or record.status != record.is_open (text and a formula field) got three answers, measured through the real plugin-security on driver-sql, on SQLite and PostgreSQL: os validate called it valid, every read it scoped answered INVALID_FILTER / 400 and every by-id update or delete it scoped 403, and an insert or update its check judged, or its using standing in as the check, was admitted and stored, because the write check compared the two raw values. The classification is now exported once from @objectstack/spec/data (crossFieldComparisonVerdict) and read by every judge. The authoring arm: the rls-predicate-unenforceable rule refuses the comparison in using and check, on every operation, at os validate, build and lint and at the metadata save door for a permission set, and the sharing-rule-unlowerable-condition rule refuses it in a sharing-rule condition at os validate, build and lint. The write-check arm: the row-level write gate hands matchesFilterCondition the object's declared columns, and a comparison the classification does not define is refused INVALID_FILTER / 400 for every insert and update the check judges, before any record is read, with nothing stored; the message withholds the columns and the server log names the policy and both. A comparison against a json or multiple field is now refused by its declared type on the write too, where the earlier refusal of an array comparand under $ne judged it by the value each record held. driver-memory, a test driver with no field-reference arm, still reads such a comparison as a literal. Shipped producers were counted before the change: no shipped row-level policy or sharing-rule condition compares two fields of different classes. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison the author meant, and rewriting it on the author's behalf would change which rows and writes the policy admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112.
    • Done when: Run os validate over your stack: it names every row-level or sharing-rule predicate that compares two fields of different comparison classes, with both declarations. Rewrite each as the replacement says. A policy that never passed os validate (stored before the authoring arm, or written by another path) is refused at request time instead: every read it scopes answers 400 on the SQL drivers, and so does every insert or update its check judges, so re-check what each such policy is meant to admit rather than assuming the writes it admitted before were right.
  • rls-predicate-stored-list-ordering-refused — security.PermissionSet rowLevelSecurity[].check, and .using where it stands in as the check — a CEL predicate ordering a field against a bound (>, >=, <, <=) where the field holds a list or an object on the record being written, as a json column or a multiple lookup does, or as a list written into a text or number field does. In a filter passed to matchesFilterCondition, $gt / $gte / $lt / $lte and $between on a field whose value on the record is a list or a plain object, whatever the comparand → a comparison that names one value. Order a single-valued column (record.priority > 2), or test membership in the list with in (record.status in ["open", "pending"]); a json or multiple field has no ordering. A record whose json column holds one scalar is compared exactly as before, and so are null and Date values, and every equality (==, !=, in) against a stored list
    • Why not automatic: The mirror, with the list on the record's side, of the earlier refusal of an ordering operator against an array comparand (one of the same-class leaks that followed the 2026-09-24 ruling refusing an array under $ne), measured through the real plugin-security on driver-sql and driver-memory. record.tags > "a", with tags a json column holding ["m"], lowered to { tags: { $gt: "a" } }, and the write-check evaluator compared the list's JavaScript string form ("m" > "a"), so the check admitted and stored the write; record.meta < "a" with meta holding { a: 1 } compared "[object Object]" and did the same, and so did a multiple lookup. driver-sql's read refuses every ordering comparison, and $between, on a column it stores as JSON text, by declared type (400), because such a comparison can never mean what the caller wrote; the in-process write check now follows it, per record: INVALID_FILTER / 400 and nothing stored, on an insert and on a by-id update, including one that edits another field of a row whose stored column holds a list. A list written into a text or number field under an ordering check, admitted before and stored as the text "[500]" by driver-sql, is refused the same way. driver-memory, a test driver, still compares a stored list element by element on a read, so there the write and the read part. Shipped producers were counted before the change: no shipped row-level or sharing-rule predicate orders a field at all. Metadata AT REST is not rewritten and this entry adds no D2 conversion: the platform cannot tell which comparison an ordering over a list was standing in for, and rewriting it on the author's behalf would change which writes it admits, which is the policy author's decision. ADR-0058 D4 / ADR-0087 / ADR-0112.
    • Done when: Grep the rowLevelSecurity check predicates of your permission sets, and the using predicates of policies that declare no check, for >, >=, < or <= whose field is a json field or a multiple lookup, and rewrite each as the replacement says. Then write a record through each such policy: a write whose compared field holds a list now answers 400 rather than being admitted by string comparison, so re-check what the policy is supposed to admit.
  • saved-report-stack-retired — the saved-report stack, whole: the reports platform capability token (requires: ['reports'], its PLATFORM_CAPABILITY_TOKENSmember and itsPLATFORM_CAPABILITY_PROVIDERSrow); the saved-report service contract in@objectstack/spec/contracts (IReportService, SavedReport, ReportSchedule, ReportQuery, ReportFormat, ReportRunResult, SaveReportInput, ScheduleReportInput); the sys_saved_reportandsys_report_schedule platform objects (SysSavedReport/SysReportSchedulein@objectstack/platform-objects/audit) and their names in PLATFORM_PROVIDED_OBJECT_NAMES; the eight /api/v1/reportsroutes (list, save, get, delete, run, schedule, list schedules, unschedule); thereportsnamespace of@objectstack/client; and the @objectstack/plugin-reportspackage that served them. NOT thereport metadata kind (ReportSchema, /meta/report, datasets, analytics), which is unchanged. → Delete 'reports' from requires — defineStack now refuses it with this prescription. A report is report metadata: ReportSchema over a dataset (ADR-0021), served by the analytics service every server mounts. A saved ad-hoc object query — what a sys_saved_report row held — is a ListView on that object. Code that imported the contract types or called the client namespace deletes those lines; there is no successor API and no scheduled-delivery replacement.
    • Why not automatic: Maintainer ruling 2026-09-25 (verbatim: 「A. 退役」, then 「你直接派发处理这个退役任务。」). The stack persisted a raw object query (object_name plus {filter, fields, orderBy, limit, groupBy}) with a render format and an owner — the same object-plus-raw-query shape ADR-0021 removed from the report kind as its legacy inline query form, alive in a parallel table under the same word. Measured on the main branch of all three repos before removal: zero callers of the routes, the client namespace or the service contract outside their own tests, and no app declaring the capability. A declared capability with no consumer is a surface an author (most often a model) reaches for and confuses with the real report kind, so it is retired at once, with no deprecation window.
    • Done when: defineStack({ requires: ['reports'] }) throws STACK_CAPABILITY_UNKNOWN (422) whose message names the retirement and the replacement; classifyRequiredCapability answers unknown for the token; every /api/v1/reports path answers the standard unmounted-route 404; nothing imports the retired contract types or objects (TS2305 after upgrade). Existing sys_saved_report / sys_report_schedule tables in deployed databases are left in place untouched — no backfill, no reaper, no drop (the platform never drops a table that metadata stops declaring); os migrate plan lists them in its informational unmanaged-tables section, and dropping them is the operator's decision.
  • schedule-flow-acting-organization-required — The START NODE config.organizationkey of every time-triggered flow — atype: 'schedule'flow carrying aconfig.schedulecadence, and thetimeRelative sweep that carries its cadence in the same slot (FlowTriggerKind schedule/time_relative) — TOGETHER WITH the deployment variable that decides whether such a flow arms at all, OS_AUTOMATION_SCHEDULED_WORK_ENABLED. Nothing is renamed, retired or re-typed: the start node's configis an OPEN record (ADR-0018), so the key is an ADDITION to a slot that already accepted it, and every flow that parses today parses byte-identically after the change. What narrows is the BIND-time accept set and the RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further is WHERE that narrowing applies: the declaration is required under tenancy postureisolatedonly, is OPTIONAL undergroup(where an undeclared run acts as the swept record's own organization), is not read undersingle, and no time-triggered flow arms anywhere until the deployment switches package-authored scheduled work on. → Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS PACKAGE-AUTHORED SCHEDULED WORK AT ALL: OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true arms time-triggered flows and packaged defineJob cron jobs; unset — the global default, in every posture and every kernel — arms neither, and every such flow is listed by getTriggerBindingAudit() and the CLI startup summary as DISABLED BY DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs (approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS isolated, declare the organization each flow runs as, on the start node beside the cadence: config: { schedule: { … }, organization: '<sys_organization.id>' }. There is deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per organization — and deliberately no fallback: nothing on this path ever chooses an organization, because a wrong organization_id is silently authoritative to every report, export and cleanup that filters by organization, while a refusal is visible at boot and names its flow. Under the single posture with the switch on, declare NOTHING: the run carries no organization and every tenant-scoped insert beneath it resolves the deployment's one organization through the guard that makes a system-context write resolve the install's organization. Under the group posture with the switch on, declaring is OPTIONAL and both shapes are supported: a declared flow behaves exactly as under isolated (the declaration bounds SELECTION and identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 makes inherent to the posture — and stamps each run it launches with the SWEPT RECORD's own organization, the same subject-first order sys_automation_run already uses. ⚠️ An undeclared group flow that reaches a tenant-scoped write with NO record to derive from — a record-less cron emitting a notification — is REFUSED at that write (walled-posture, ADR-0112), loudly and by name; declare config.organization on that flow, which is the remedy the refusal itself prints. ⚠️ Three consequences apply to an isolated deployment that splits one flow into N, and each is deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read (org = :tenant OR org IS NULL), so after the split each such row is matched ONCE PER FLOW — N runs and N notifications for one row, each acting as a different organization; (2) the dispatch-claim key embeds the flow name (schedule:<flowName>:<window>, time-relative:<flowName>:<scope>:<recordId>), so renaming one flow into N abandons the current window's claims and a window already delivered under the old name can deliver once more under the new ones; (3) a run SUSPENDED before the upgrade rehydrates its context from context_json, which carries no tenantId, so it resumes org-less — drain or accept in-flight suspended runs rather than assuming the upgrade confines them retroactively.
    • Why not automatic: Three maintainer rulings, all verbatim and untranslated, in the order they were given. 2026-09-08: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered run is launched from a job tick and a job tick carries no identity, so the run reached the tenancy guard with nothing to offer it: the notification wrote organization_id = NULL, every tenant-scoped row beneath it was refused, and the tick still summarised itself as healthy. 2026-09-12, on the same surface: 「schedule 是风险很大的模型,尤其在云端,无算是单独多租户还是每库一租户,可能造成极大的资源浪费。对于单租户或着集团版私有部署,我觉得不需要做限制。定时任务 如果不好处理,现在也没想清楚,有没有可能定义为一个环境变量,根据环境变量控制?」 and 「group 默认也关,云端每库一租户全局默认关」. Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, its tenants, its budget — that no author can know and no metadata key should ask them for, so the gate is a deployment variable read at boot and the global default is OFF. 2026-09-16, reopening the group half of that amendment and nothing else: 「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ruled A′ the same day: under group with the switch on, a flow binds without a declaration. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and group is not one: ADR-0105 D1 defines it as one legal group over one database with group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level batch job is a capability of the posture rather than the cross-organization task the ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item 3 — was which organization such a run's inserts belong to, and the answer is the one sys_automation_run was already ruled to use: the SUBJECT RECORD's organization, with the acting context as the fallback and never the primary. Filling the acting context the same way makes the inbox, delivery and history rows of one run agree about its owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is recorded too, because it is the one a later reader will re-propose: falling back to the bootstrap organization (slug='default') for a record-less run. Under a wall that organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not exist at all, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many. That is the silently-authoritative wrong owner this entry already forbids, so a record-less undeclared run is refused instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under isolated, is satisfied per-record under group, and is moot under single, which holds exactly one organization and therefore has no cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is that both remedies are values only the deployment holds: an organization id is minted per install at runtime and the switch is an operator decision about cost, so there is no authored artifact and no stored representation a transform could rewrite — objectstack migrate meta cannot know which organization a given sweep belongs to, nor whether this deployment wants scheduled work at all, and inventing either is precisely what the rulings forbid. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription — "decide the switch, then declare one flow per organization under a wall" is deployment work a human must do, which is what D3 says a structured TODO is for. The direct precedent is rest-requireauth-default-flip (protocol 12): behaviour-only, no shape moved, a deployment judgement no transform can make, registered anyway.
    • Done when: The deployment has DECIDED the switch, and the decision is visible: os doctor prints the effective OS_AUTOMATION_SCHEDULED_WORK_ENABLED value. A deployment that leaves it unset — the default — accepts that no packaged time-triggered flow and no packaged defineJob runs, and confirms that every such flow appears in getTriggerBindingAudit() and the CLI startup summary with the reason DISABLED BY DEPLOYMENT POLICY and NOT as "binding failed"; no boot line reads [schedule] NOT BOUND / [time-relative] NOT BOUND, because nothing was refused for a declaration. A deployment that sets it to true under posture single confirms that its time-triggered flows are armed while declaring no config.organization, and that the runs they launch carry none. A deployment that sets it to true under posture group confirms the shape it wants PER FLOW: for a flow it left undeclared, that boot logs the bind line naming per-record ownership, that a sweep tick launches runs stamped with each swept record's own organization (NOT one organization for the batch), and that any record-less cron among them either declares config.organization or is accepted to fail loudly at its first tenant-scoped write; for a flow it declared, the isolated criteria below apply unchanged. ⚠️ The discriminating observation for the undeclared case is the SET of organizations across the runs one tick launched — a pin that reads only "a run was stamped" passes on the defect too, which stamped them all alike. A deployment that sets it to true under posture isolated confirms that every schedule / time_relative flow in the stack declares a non-empty config.organization on its start node, that boot logs no NOT BOUND line, that getFlowRuntimeStates() reports bound: true and that getTriggerBindingAudit() lists no time-triggered flow — and, where it ran ONE flow across all organizations, that it has split it into one flow per organization and re-checked the three consequences above (NULL-tenant rows, abandoned dispatch claims, suspended runs). ⛔ There is NO authoring-time lint for the declaration: it was retired with this amendment because neither the switch nor the posture is knowable from a stack, so os lint reporting nothing is the criterion being met, not a check that was skipped. ⚠️ @objectstack/driver-memory has NO legal configuration for a time-triggered flow that touches per-organization data under a wall: it refuses any call handed a tenant scope (MEMORY_MULTI_TENANT_UNSUPPORTED), so a declared flow is refused per call, an undeclared isolated one is not armed at all, and an undeclared group one arms and sweeps unscoped but is refused at the first write it derives an organization for. Multi-organization deployments use @objectstack/driver-sql.
  • scim-provider-object-retired — the sys_scim_provider platform object (SysScimProviderin@objectstack/platform-objects/identity, re-exported from the package root) and its name in PLATFORM_PROVIDED_OBJECT_NAMES (@objectstack/spec/systemconstants). The rc.1-era@better-auth/scimconnection row: one row per SCIM bearer connection, written only by the retired/scim/generate-token endpoint. → (removed — no direct replacement row. The stable @better-auth/scim 1.7.x line, which the platform adopted as one whole-model migration, derives no scimProvider model: SCIM state lives in the seven stable platform objects (sys_scim_connection_binding, sys_scim_group, sys_scim_group_member, sys_scim_identity_tombstone, sys_scim_projection_grant, sys_scim_subject, sys_scim_user) and connection credentials in the ObjectStack-owned sys_scim_connection_credential, minted/verified by scim-connection-service.ts behind the application-owned verifyBearerToken. A SCIM-enabled deployment re-registers its connections on the stable surface; rc.1 token digests are not portable on any path, so the IdP reissues its token — a migration-day operator action, not a code rewrite.)
    • Why not automatic: Maintainer ruling 2026-08-24 on the disposition of sys_scim_provider (verbatim, in part: 「不需要考虑历史数据」) — disposition A: retire, with no data-migration path owed for existing rows (reaffirmed 2026-08-25: SCIM has no real customers; the binding constraint is a smooth upgrade). Executed as a retirement of its own after the stable-1.7.1 migration landed: the installed library derives no scimProvider model, so the object backed nothing — nothing could write a row to it any more. Retiring it also removes its provider_id unique index, whose stricter-than-upstream uniqueness (one provider_id across every organization, where upstream scopes it per organization) was flagged while the SCIM upgrade was parked, and left pending exactly this retirement.
    • Done when: No code imports SysScimProvider from @objectstack/platform-objects (TS2305 after upgrade); isPlatformProvidedObjectName('sys_scim_provider') returns false, so a stack referencing the name is flagged as a probable typo rather than resolved; plugin-auth provisions no sys_scim_provider object and AUTH_MODEL_TO_PROTOCOL carries no scimProvider entry; the spec registry conformance test (platform-object-names.test.ts) pins the absence bidirectionally — re-adding either the object file or the registry name alone reds registry group "platform-objects" is out of date (measured both ways when the object was retired). Existing sys_scim_provider tables in deployed databases are left in place untouched, by ruling — no backfill, no reaper, no migrate command.
  • screen-field-lookup-reference-required — The referencekey of atype: 'lookup'field on ascreennode —flows[].nodes[].config.fields[]where the nodetypeisscreenand the fieldtypeislookup (ScreenFieldConfigSchema). Nothing is renamed, retired or re-typed and the key set does not move: referencewas already declared and already optional in the shape. What narrows is the ACCEPT SET for one value of the siblingtype— alookupfield with noreference, or with a blank one, parsed before this major and is refused now. Every other widget hint is untouched, and a lookup field that already names its target parses byte-identically. → Name the object whose records the picker offers, beside the type: { name: 'resolved_by_article', type: 'lookup', reference: 'crm_knowledge_article' }. The value is an object NAME (the canonical id — same string FieldSchema.reference carries), not a label and not a record id. ⚠️ There is deliberately no default and no inference: a picker pointed at the wrong object is worse than one that refuses to load, because it offers a human a plausible list of the wrong records and the flow stores the id it is given. Where the field genuinely has no target object — the author was using lookup to mean "type an id here" — the fix is the other direction: change type to 'text', which is what that field actually was, and keep the prose that asked for an id in inlineHelpText.
    • Why not automatic: Maintainer ruling A′, 2026-09-13, verbatim, untranslated: 「同意」. ADR-0078 forbids metadata that parses, carries no marking and does nothing — and its own worked example of that state is a lookup with no reference: the field renders a picker, the picker has no object to query, and nothing anywhere says so. The key shipped OPTIONAL on this surface one release earlier, on the argument that flows declaring a bare lookup already exist; the ruling reversed that, holding that a degraded shape which ships is not a reason to bend the contract to it. ⛔ NOT losslessly convertible, and the reason is the same one schedule-flow-acting-organization-required gives: the remedy is a value the artifact does not contain. A bare lookup records the field name and nothing about its intended object, so objectstack migrate meta can identify every site but can answer none of them — and a conversion that guessed (the first object with a matching-looking name, the flow's trigger object) would write an authoritative wrong answer into metadata a human then trusts. Registered under ADR-0087 D3 rather than left silent because the change DOES carry a prescription a human can execute, which is what D3 says a structured TODO is for.
    • Done when: Every type: 'lookup' field on every screen node in the stack declares a non-empty reference, and the stack parses: ScreenFieldConfigSchema refuses the bare form with a message addressed to reference (SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED), so a full metadata parse — os lint, or any publish — reports one issue per unfixed site and names the FLOW and the FIELD in its path. Work the list to empty rather than sampling it: a flow whose screen never reaches that node in testing is refused at publish just the same. For each site, answer which object the picker was meant to offer — the declaration is the answer, and where there is no such object the field was never a lookup (retype it 'text'). ⚠️ Runs SUSPENDED at a screen before the upgrade rehydrate their ScreenSpec from stored context, so an in-flight run parked on an unfixed screen carries the old shape: drain or re-drive those rather than assuming the fix reaches them retroactively.
  • security-catalog-environment-overlay-refused — the cold boot of a deployment whose sys_metadata holds an active, environment-wide row of type permission or position (or the legacy plural permissions or positions) under the name of a permission set or position a configured package declares, the platform security plugin's shipped sets included → before the first boot on this major, run os migrate security-catalog-overlays with the flags and environment the deployment boots with (--preset and --dev mean what they mean to os serve): it lists exactly those rows, each with the package that holds its name. Then run os migrate security-catalog-overlays --apply to delete them through the metadata write path (a history tombstone per row). Or rename the item in the package. Nothing is adopted: an item the environment needs under its own name is re-created under a name no package holds
    • Why not automatic: Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot now refuses it too, right after the stored rows load: the stored row used to be served in place of the package's definition, with only a collision warning. Rows like this exist on deployments that saved over a package-held name before the packaged locks refused such saves, and over the security plugin's own sets, which it declares only where the boot composes it behind the auth gate (an auth secret set, or a development boot). The refused deployment cannot start, so no in-server action can clear the rows, and the metadata API reaches no legacy-plural row at all; the offline step can. No conversion applies: the rows are the environment's own work, and whether to drop or rename one is the operator's call, which the step's preview puts in front of them. ADR-0048, ADR-0087.
    • Done when: On the deployment's database and configuration, with its boot flags and environment, os migrate security-catalog-overlays lists no row (exit 0). The listing covers permission sets and positions and the legacy plural spellings, and never an organization-scoped or a draft row. After --apply, the next boot of the same database and configuration comes up, where before it was refused with NAMESPACE_CONFLICT (422) naming the environment catalog as the holder.
  • send-template-input-org-retired — contracts.emailService.sendTemplate input.org → (removed — never implemented; delete the key from the call. It is NOT replaced by organizationId: that member is the delivery row's tenant stamp (sys_email.organization_id pass-through, added so the email writer stamps a delivery row's organization at the source) and opts into no template overlay resolution)
    • Why not automatic: ADR-0049 enforce-or-remove. SendTemplateInput.org was declared as "Tenant id for org-overlay resolution (when supported)" and no implementation ever read it: @objectstack/plugin-email — the only IEmailService implementation — resolves templates on (name, locale) only, so a caller passing org got no org-overlay resolution and no error; the "(when supported)" hedge was the declaration admitting the gap. After the delivery-row stamp landed organizationId beside it, the input carried two org-shaped keys of which one did nothing — exactly the shape that invites an AI author to pick the wrong one. There is no behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a call-time input bag (the data.engine.update options.upsert precedent), which is why this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. Org-overlay template resolution, if it ever earns a measured business pull, is a new capability with its own ruling — not this key revived.
    • Done when: No caller passes org to IEmailService.sendTemplate(). The enforcement channel is the compiler: SendTemplateInput is a programmatic contracts interface with no Zod surface, so authoring org is an excess-property tsc error (pinned in packages/spec/src/contracts/email-service.test.ts). Runtime behaviour is deliberately UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a JavaScript caller still passing org keeps its exact pre-removal outcome (the key is carried inert and ignored). Template resolution still keys on (name, locale), and organizationId still stamps sys_email.organization_id without acquiring any overlay semantics.
  • session-payload-positions-security-axis — GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root current_user.positions bound from it) → the SAME key, carrying the SECURITY positions — the set /auth/me/permissions reports and resolveUserAuthzGrants resolves. A reader that wanted the better-auth role scalar reads user.role, which is unchanged and still published
    • Why not automatic: A MEANING change, not a rename: no key moved, so nothing in this entry can be found by grepping for a removed spelling — which is exactly why it needs a ledger row. customSession built user.positions from the better-auth sys_user.role scalar split on commas, PLUS the active membership mapped to org_*, PLUS platform_admin, and read NOTHING from sys_user_position (ADR-0057 D4), the source of truth for custom positions. The Console binds that array straight through as the CEL root current_user (objectui expressionUser.ts: positions: user.positions ?? []), so an action.visible / visibleWhen / nav visible narrowed by a business position answered FALSE for EVERYONE, including the user who genuinely held it. ⭐ The failure was silent and in the invisible direction: the root was bound and the key was present, so has(current_user.positions) was true and CEL raised nothing — the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell and would have shown the button; a successful FALSE shows nothing and reports nothing. The documented example 'org_admin' in current_user.positions kept working throughout, because org_admin is the one name that sits on BOTH axes — which is why no example, test or doc could reveal the split. This was a DECLARED contract being violated rather than an ambiguous name: EvalUserSchema already specified positions as "built-in identity names + position names", exposed to "every predicate surface (server formula, server RLS, client UI gates) ... with an identical shape" so a predicate "evaluates identically wherever it is written". /auth/me/permissions and every server-side evaluator (ExecutionContext.positions) already resolved the security axis; the session payload was the one producer that did not, because it derived the value itself instead of asking the authority resolve-authz-context.ts reserves that job for. ⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition rather than an omission: everything the old union contributed beyond the security axis was the sys_user.role scalar's own tokens, and that scalar is ALREADY published unchanged as user.role — the single exception ADR-0090 D3's "role" word ban carves out for third-party schema. Minting a roles array would revive the exact banned identifier check:role-word ratchets against, to publish information the payload already carries. Maintainer ruling 2026-09-05: option A, one name, one meaning — current_user.positions means the security positions everywhere. ADR-0068 D1/D2, ADR-0090 D3/D5, ADR-0057 D4.
    • Done when: No predicate and no client reader treats current_user.positions / session.user.positions as the better-auth role scalar. Audit every authored visible / visibleWhen / RLS predicate that names current_user.positions and classify each comparand: a real sys_position name, a built-in identity name (platform_admin / org_owner / org_admin / org_member), or everyone needs NO change and starts working where it silently answered FALSE before; a comparand that was only ever a sys_user.role token (user, and admin — note admin is NOT a built-in identity name; a membership admin is projected as org_admin) either moves to user.role, or — the supported route — becomes a real position assigned through sys_user_position, the governed ADR-0090 D12 channel. ⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name that exists on ONE side only: org_admin sits on both and cannot discriminate, which is precisely how this defect survived its own documented example. Sign in as a user holding a custom position, read GET /api/v1/auth/get-session, and assert that position is in user.positions and that the payload agrees set-for-set with GET /api/v1/auth/me/permissions. Assert the absence of a role-scalar token by VALUE rather than by the predicate's verdict: a gate that reads false cannot tell "the name is gone" from "the name was never there", and both of those from a faulting predicate, which fails OPEN in the shell and renders anyway. A deployment that stored business role names in sys_user.role instead of assigning positions is the one that must act; a name in sys_member.role is still projected, so membership-derived names are unaffected.
  • session-user-language-retired — api.session.user.language → GET /auth/me/localization → locale (the user's own sys_user.locale when set → the request's Accept-Language → the deployment default)
    • Why not automatic: SessionUserSchema.language was declared with a permanent default of 'en' and described as "Preferred language", and had no producer and no consumer anywhere: no session endpoint wrote it, no client read it (objectui measured zero readers at its pinned sha), so a reader trusting the published contract received a constant that was not the user's language. Meanwhile the user's real preference landed as the first-class column sys_user.locale (ruled 2026-09-01 once measured demand for a per-user notification locale arrived), which the session type could not see — three spellings of one concept on the published surface, none of them right. The maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 enforce-or-remove and make GET /auth/me/localization the ONE read face, with its locale projecting the user column first. This is a RESPONSE surface — the server mints a SessionUser and nobody authors or persists one — so there is no source for the chain to rewrite; the schema tombstones the key via retiredKey() and consumers move their read to the endpoint. No replacement field joins the session contract until a session endpoint really produces one (no dual-spelling window, 不渐进). ADR-0049, ADR-0087.
    • Done when: No client reads user.language off a SessionResponse / UserProfileResponse; a client that seeded its UI language from it now reads locale off GET /auth/me/localization, where a user who set sys_user.locale sees that value, a user who did not sees the request's Accept-Language preference, and a request expressing none sees the deployment default. Constructing a SessionUser with language fails to parse with its own prescription instead of being silently stripped, and assigning it is a tsc error at the authoring site.
  • stack-config-default-export-unbuilt-refused — the default export of objectstack.config.ts when it is not the value defineStack or composeStacks returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of one, a module with no default export — and each input handed to composeStacks → export what the producer returned: import { defineStack } from '@objectstack/spec'; export default defineStack({ … }); with every stack key inside the call (api, plugins, requires, …), or export default composeStacks([defineStack({ … }), …]). Named exports beside it (onEnable, functions) are unaffected. defineStack(config, { strict: false }) also satisfies the doors — it is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read
    • Why not automatic: The stack family's cross-field refusals — unknown requires capability, cross-references to objects the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and trigger capability requirements — run inside defineStack and nowhere else. A config exporting a plain object skipped all of them: objectstack validate and objectstack build ran only the schema parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or never (a trigger flow that silently never fires). Judging the export at the door instead is not possible: a built stack carries each bound standalone action twice (top level and merged into its object), so re-running the family on defineStack output refuses every correct project with a bound action. So both producers stamp a non-enumerable provenance mark on what they return (hasStackProvenance), and objectstack validate / objectstack build refuse an unmarked default export right after load with STACK_PROVENANCE_MISSING (exit 1), before any other judgement; composeStacks refuses an unmarked input with the same code. A copy of a built stack is refused too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. objectstack serve, objectstack migrate and objectstack lint load the config as before. ADR-0087.
    • Done when: Run objectstack validate (and objectstack build) in every project. A refusal prints objectstack.config.ts: the default export was not built by defineStack and, under --json, carries code: STACK_PROVENANCE_MISSING. Wrap the export in defineStack({ … }), move any key that was spread onto a copy inside the call, and re-run: the command either passes or now reports the stack family's own findings (STACK_CAPABILITY_UNKNOWN, STACK_CROSS_REFERENCE_INVALID, …) that the plain export had been hiding — fix those as each message prescribes. A project already exporting defineStack(...) or composeStacks([...]) of defineStack inputs is unaffected and passes byte-identically.
  • stack-themes-carrier-retired — stack themes(the carrier collection, andThemeSchema with its sub-blocks) → delete the themes: key (and any defineTheme calls). To colour the shipped console, set app.branding.primaryColor / accentColor — the one live colour surface (read by objectui, driving --primary, --accent and their derived CSS variables). A palette value your own stylesheet consumed has no spec slot any more: move it into your own CSS.
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-08-21 (disposition B: 退役授权面 — objectui engine code and its unit tests are retained). The pipeline was live from the authoring gate (ObjectStackDefinitionSchema.themes, defineTheme) through artifact ingest (ARTIFACT_FIELD_TO_TYPE.themes) and stopped there, measured: zero non-test readers of .themes or stored theme items across core/runtime/rest/services/plugins; theme never in MetadataTypeSchema, DEFAULT_METADATA_TYPE_REGISTRY or BUILTIN_METADATA_TYPE_SCHEMAS; the only mounted ThemeProvider is the app-shell chrome light/dark toggle, unrelated to ThemeSchema; and no key anywhere selected an active theme. So an author (human or AI) who wrote a theme shipped it through every green gate and saw nothing change — the declared-but-unenforced shape ADR-0049 exists to delete. What colours a console today is app.branding, and that path is live and untouched.
    • Done when: No stack source authors themes:; a stack that still does is refused at parse with the prescription (unrecognized_keys carrying the retirement's guidance — pinned in stack-top-level-strict.test.ts). PUT /meta/theme/:name gets the unrecognised-type refusal (a /meta type name the platform does not have is refused, never minted as a namespace) instead of the store-anything branch it had before theme was validated at the /meta write door (pinned in protocol.unrecognised-meta-type.test.ts). Legacy stored theme rows are untouched: applyConversionsToStoredItem passes them through, reads still answer, and DELETE still works, so the residue is removable. ⚠️ On-screen behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read an authored theme, so removing the surface removes no behaviour — app.branding colours the console before and after.
  • stack-top-level-unknown-keys-refused — top-level stack definition keys (ObjectStackDefinitionSchema) — undeclared keys → the declared top-level surface. A key the schema does not declare is refused at parse with a prescriptive message naming the key, suggesting the closest declared key on a near miss (objectz → objects, flow → flows), and carrying a curated prescription for the known retirements (approvals/approvalProcesses → Approval-node flows per ADR-0019; workflows → state_machine validation rules per ADR-0020; portals removed with the dead PortalSchema; storage is deployment config, OS_STORAGE_*; onDisable was never invoked, and left with the lifecycle-hook family the kernel never implemented). onEnable is now DECLARED rather than silently stripped — the runtime has always executed it off the authored bundle (a config-booted app keeps it too, since the fix that stopped the loader dropping it and every script action handler it registered)
    • Why not automatic: The outermost authoring door was the last strip-mode surface of the unknown-key strictness campaign: an unknown top-level stack key parsed green and its value was silently dropped. Measured on 17.0.0 GA: three injected bogus top-level keys added ZERO warnings to os validate and exited 0 — even --strict could not catch them, because the defineStack: naming diagnostic printed at load, outside the warning tally. The failure population is a typo or stale key (flow for flows, approvalProcesses after the 7.4 removal) shipping an artifact with a whole metadata family absent at runtime, debugged from the far end — the root of a downstream application's report of a top-level typo that shipped an artifact minus a whole family with validate and build both green. Unknown top-level keys are now refused at parse time, which fails validate (and every other path through this one parse) outright; the near-miss guidance that used to arrive as a load-time warning now rides the refusal itself.
    • Done when: A stack authoring only declared top-level keys parses byte-identically to before, onEnable/functions included. Any undeclared top-level key fails the parse with unrecognized_keys naming the key; a one-edit near miss carries a rename suggestion; the curated retirements answer with their prescriptions. os validate exits non-zero on a stack carrying any undeclared top-level key, with or without --strict.
  • standard-error-code-batch-members-retired — ``error.codevaluesBATCH_PARTIAL_FAILURE, BATCH_COMPLETE_FAILUREandTRANSACTION_FAILED— threeStandardErrorCode members retired from the closed catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError with any of them now refuses at the vocabulary boundary → branch on the codes the batch surface actually speaks: a rolled-back atomic batch marks each row errors[0].code = ROLLED_BACK, rows the abort never reached NOT_ATTEMPTED, and the causal row keeps its own error — all per row, at HTTP 200, both codes ledger-registered. Delete any branch on the three retired spellings outright: it never fired, because nothing ever emitted them
    • Why not automatic: ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever emitted any of the three — measured when a sweep of the error catalogue found these three entries publishing no HTTP status: outside the enum declaration the only occurrences in the whole repo were two spec tests using them as arbitrary fixture strings, and git log -S shows they never had a producer since ADR-0112 introduced the vocabulary. A catalog member no producer can speak teaches an AI author a branch that can never fire; after removal the wrong spelling fails parse at authoring time instead. This is a WIRE vocabulary, not stored metadata — no sys_metadata row exists for the D2 chain to rewrite, so (like driver-sql-upsert-cross-row-identity-merge-refused) this entry is the notification channel. No mechanical rewrite exists: a dead branch has no correct mechanical target — the per-row codes carry strictly more information than the envelope code the branch expected. Maintainer ruling 2026-08-18: option A, retire all three from StandardErrorCode. ADR-0112, ADR-0049.
    • Done when: No consumer branches on the three retired spellings; batch failure handling reads the per-row results[].errors[].code (ROLLED_BACK / NOT_ATTEMPTED) instead of an envelope-level code; constructing an ApiError with a retired spelling fails StandardErrorCode/ApiErrorSchema parse rather than passing silently.
  • standard-error-code-concurrent-limit-exceeded-retired — error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the closed catalogue, so constructing or parsing an error with it now refuses at every catalogue door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the removal prescription → delete any branch on CONCURRENT_LIMIT_EXCEEDED — a branch on a catalogue code no producer emits has nothing to match. For request pacing branch on RATE_LIMIT_EXCEEDED (HTTP 429; wait retryAfterSeconds before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger rather than reusing the retired spelling. QUOTA_EXCEEDED, its catalogue neighbour, is unchanged.
    • Why not automatic: ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A of 2026-09-13 (maintainer 「同意」) retired both producerless 429 members; the closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to this code alone after QUOTA_EXCEEDED was found emitted by a hosted AI agent route and read by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a producerless row with no card behind it as the registered-but-unemittable retirement class, and a catalogue member no producer speaks teaches an author a branch that cannot fire; after removal the stale spelling fails parse with its prescription instead. An error code is WIRE vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite and this entry is the notification channel, as it was for standard-error-code-batch-members-retired. No mechanical rewrite exists: a dead branch has no correct mechanical target.
    • Done when: No consumer branches on CONCURRENT_LIMIT_EXCEEDED; request pacing is handled on RATE_LIMIT_EXCEEDED. Constructing or parsing an error with the retired spelling fails StandardErrorCode, ErrorCode / ApiErrorSchema and the makeApiErrorSchema envelope parse, and the failure message is the removal prescription rather than the bare enum listing. QUOTA_EXCEEDED still parses at every door.
  • startup-orchestrator-retired — the startup-ORCHESTRATION surface of kernel/startup-orchestrator.zod.ts and contracts/startup-orchestrator.ts — 3 emitted defs and 8 exported names: StartupOptionsSchema / StartupOptions / StartupOptionsParsed, HealthStatusSchema / HealthStatus, StartupOrchestrationResultSchema / StartupOrchestrationResult, and the IStartupOrchestrator interface (orchestrateStartup / rollback / checkHealth / startWithTimeout). The startup RESULT survives, re-declared: PluginStartupResultSchema and PluginStartupResult stay on both entries → (removed — there is no declarative replacement, because nothing ever implemented the interface or parsed the schemas. Plugin startup is the kernel own boot loop: ObjectKernel.start() calls startPluginWithTimeout() per plugin, which races that plugin start() against PluginMetadata.startupTimeout and, when KernelConfig.rollbackOnFailure is set, destroys the already-started plugins and rethrows the original error as the new error cause. So: instead of StartupOptions.timeoutMs declare startupTimeout on the plugin; instead of StartupOptions.rollbackOnFailure set rollbackOnFailure on the kernel config; instead of StartupOrchestrationResult.results read the per-plugin durations through ObjectKernel.getPluginStartupDurations(). StartupOptions.healthCheck and HealthStatus have NO replacement at all — no startup probe system exists, and one returns only through the enforce route of ADR-0049 with a new ADR, the probe first and the vocabulary second. StartupOptions.parallel and StartupOptions.context likewise: the kernel starts plugins sequentially and passes its own PluginContext)
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-06, option 3: keep a startup-result contract re-declared as the shape the kernel ships, and retire the rest. The module declared an orchestration design that never landed, and the spec and the kernel had already drifted into disagreement about the one shape that did: PluginStartupResultSchema described a plugin object, a required durationMs and a health member, while @objectstack/core shipped pluginName, an optional durationMs and timedOut. The ruling keeps a startup-result contract that describes what the kernel actually produces, and retires the rest. Re-measured on this card: zero implementers and zero consumers of the four retired surfaces in this repository and in the pinned objectui checkout, with lit same-corpus controls (defineStack, ManifestSchema); every remaining reference was a generated artifact or a released CHANGELOG.md. healthCheck and HealthStatus are the sharpest of the four: they name a per-plugin health probe the runtime has never had, the shape of the plugin sandboxing / integrity / approval config that was never wired to anything, which an AI author (ADR-0033) reads as proof the capability exists. With no authored document carrying any of the three defs there is no seam for a D2 conversion and no author to tombstone for: route 3, the shape of the dynamic plugin-loading family's removal and the advanced plugin-lifecycle config's retirement — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. The two keys of the SURVIVING result schema that leave (plugin, health) are tombstoned instead, and registered in RETIRED_KEYS_BY_MAJOR, because that def keeps emitting and its type is imported by @objectstack/core. A third key arrives on the spec surface only to leave it: core deprecated startTime alias, which held the same elapsed milliseconds as durationMs under a name that promises an instant. The re-declaration had to either mirror it or tombstone it, and mirroring is refused by check:duration-unit-keys (ruling B on duration-shaped number keys: the unit lives in the key name) since it is an elapsed number whose key name carries no unit and matches neither of that rule two schema-declared exemptions. So the L1 window closes here and the kernel stops populating it in the same change.
    • Done when: No code imports any of the 8 retired names from @objectstack/spec, @objectstack/spec/kernel or @objectstack/spec/contracts — every one is TS2305 after upgrade, pinned by resolved symbol identity in kernel/startup-orchestrator-retirement.test.ts. No metadata document needs editing: none of the three defs was reachable from a metadata-type binding, a stack collection or a manifest embed, so no authored document could ever carry one. PluginStartupResult SURVIVES on both entries with the shape the kernel ships — pluginName, success, optional durationMs, the serializable error projection, timedOut — and @objectstack/core now imports that type instead of declaring a twin, so the drift cannot recur. Writing plugin, health or startTime on a PluginStartupResult is a tsc error and a parse error carrying the rename or the deletion; a reader of the removed startTime alias reads durationMs, which has always carried the same value. Runtime behaviour is unchanged except for that one alias: nothing ever read the retired ORCHESTRATION surfaces, the kernel boot loop is untouched, and the only observable difference is that a startup result no longer carries startTime beside durationMs.
  • storage-scope-public-retired — the storage scope public — the scope of an upload request, presigned or chunked, and ObjectStorageConfig.scope (StorageScope) → another scope, or none for the default (user on an upload, global on a storage configuration), and acl: 'public_read' on the stored file record of each file that must be readable before sign-in (ADR-0104)
    • Why not automatic: A storage scope never made a file publicly readable. The download doors judge a file by its acl, the attachments scope and field ownership alone, so a file uploaded with scope public and the default acl was stored private and needs a signed-in caller, while its scope said otherwise. The value is retired rather than enforced: enforcing it would let any uploader make a file anonymous at upload, and ADR-0104 keeps acl: 'public_read' the one opt-in for anonymous download. Whether a given file must be readable before sign-in is the caller's call, so no rewrite can make it: an upload that meant public needs its stored file record marked, and one that did not needs only another scope. The upload request itself carries no acl, and every upload is stored private. The stored file record retires the value too: the scope select of sys_file no longer lists public, so a deployment that stored files with it runs the one-time operator sweep that @objectstack/service-storage exports (planSysFilePublicScopeBackfill for the dry run, then applySysFilePublicScopeBackfill), which rewrites each of those records to scope user. No access changes: no reader tells user apart from public, the storage key and the file bytes are not touched, and those files download exactly as before. Until the sweep has run, a record write that names such a file while another field already owns it is refused, because the copy it makes carries scope public. ADR-0049
    • Done when: No upload call names scope public and no ObjectStorageConfig declares it; each upload that did now names another scope or none and is answered 200. Each file that must render before sign-in has acl 'public_read' on its stored file record, and fetching it with no session serves it; fetching any other uploaded file with no session is answered 401. On each deployment, a dry run of the sweep scans zero sys_file records with scope public.
  • strategy-context-aggregation-method-narrowed — StrategyContext.executeAggregate aggregations[].method (contracts/analytics-service.ts, exported from @objectstack/spec/contracts) - the parameter type, declared as bare string → AggregationFunction (count | sum | avg | min | max | count_distinct, data/query.zod.ts) - the same closed vocabulary IDataEngine.aggregate already declares for the identical slot (AggregationNodeSchema.function; the analytics bridge renames method to function and forwards). A caller filling method from a string-typed value narrows the value to the enum - typing it AggregationFunction, or parsing with the spec's own AggregationFunction zod enum where the value enters from data. Values outside the six were never served: the bridge has parsed-and-refused them at runtime since it stopped declaring its own engine type and began parsing the method with the spec enum, and that refusal stays as defence in depth
    • Why not automatic: Maintainer ruling 2026-08-28 (option A, census-first): one slot, one declaration. Two spec-declared surfaces described the same value and disagreed about its type: IDataEngine.aggregate's aggregations[].function is the closed six-value AggregationFunction enum while StrategyContext.executeAggregate declared the same slot aggregations[].method: string, so nothing on the analytics side of that seam was compile-checked against the engine's vocabulary - an author, very often an AI (ADR-0033), writing an analytics strategy got no compile-time help and could carry any method name all the way to the bridge's runtime refusal. One slot now has one declaration. Bookkeeping: this is a TYPE narrowing on a runtime TS interface member - no authorable metadata key, no wire shape and no walked-shape def changed, so nothing lands in RETIRED_KEYS_BY_MAJOR / RETIRED_DEFS_BY_MAJOR and the surface ratchets are expected byte-identical. It is a SEMANTIC entry rather than a D2 conversion because there is no authored document or sys_metadata row for the chain to rewrite: the only consumers are TypeScript call sites, and the compile error is the channel that reaches them. In-repo census at the ruling (hard precondition, measured before the narrowing landed): every implementor and every call site filling method is legal under the enum - ObjectQLStrategy.resolveMeasureAggregation emits only the six once it refuses a custom-SQL measure up front, the two literal producers write count, and every test fixture is implementor-side and stays assignable by contravariance.
    • Done when: External implementors of StrategyContext stay source-compatible: a handler accepting method: string accepts a superset and remains assignable to the narrowed member. External callers filling method with a string-typed or out-of-vocabulary value fail tsc at the executeAggregate call site on upgrade; the fix is narrowing the value's type to AggregationFunction (parsing with the spec enum where it enters from data), never widening a local mirror of the contract. Runtime behaviour is unchanged: the bridge's parse-and-refuse accepts and rejects exactly the same sets before and after, and no stored metadata or document needs editing.
  • structured-region-body-pause-and-end-refused — The BODY of every ADR-0031 structured region — loop.config.body, each parallel.config.branches[], and try_catch.config.try/.catch — at every depth the flow parse walks. Two node populations become undeclarable there: a node whose TYPE parks the run on EVERY execution (screen, wait, approval, approval_revise) and an endnode, whatever itsoutcome. ⛔ subflowandmapare NOT in the population, although their executors also declaresupportsPause: true: they pause exactly when the child flow their config.flowNamenames pauses, which is a DIFFERENT metadata record and is not in hand while this flow is parsed. Refusing them by type would also refuseloop { map(synchronous child) }, a shape that runs correctly today, so a region-nested maporsubflow still parses and is met at RUN time instead. → Move the node onto the TOP-LEVEL graph and route the region's exit to it. For an end: delete it from the body, give the region a normal exit, and put the terminator (with its outcome / message) on the top-level graph — loop { body: [ …, end ] } becomes loop { body: [ … ] } → end. For a pausing node: hoist it out of the container — loop { body: [ try_catch { try: [ approval ] } ] } becomes a top-level approval with the loop fanning out around it, or the pausing half of the branch is split into a subflow the top-level graph calls; where the repetition is genuinely needed, make the TOP-LEVEL graph the repeating construct with the pause on it rather than nesting the pause inside a region. A try_catch whose only purpose was to contain the region's refusal has nothing left to contain and is deleted with it.
    • Why not automatic: Maintainer ruling of 2026-09-17, verbatim and untranslated: 「同意,其他也同意」, carrying the presented option C (a durable pause inside a structured region is refused at authoring time); extended the same day by a second ruling, which attached the end half (an end node inside a region body is refused as well) and ruled 禁 on building durable pause into structured regions — structured regions do not support durable pause and a region body cannot terminate the run, so this is that limit's authoring-time enforcement rather than an interim. The POPULATION was then fixed by the 2026-09-18 ruling, letter D (maintainer 「其他同意」): 「inside loop / parallel branch / try_catch (try and catch) bodies at any depth, the node types screen, wait, approval, approval_revise and end are refused by FlowSchema.superRefine … map and subflow are ⛔ not refused by type.」 A parse-time rule refuses what is STATICALLY wrong; refusing map / subflow by type would refuse a correct working shape on a guess about another record. The refusal already existed AT RUN TIME and said nothing an author could act on: the engine converts a suspension raised inside a region into an error at the region boundary, AFTER the executor has written its progress state into the enclosing scope, so a try_catch that contains that error leaves residue the next entry reads back as progress. Measured on a real AutomationEngine, loop { try_catch { map(pausing child) } } over 3 iterations x 2 items: not one item's subflow ever completed, only two of three iterations reached the catch, and iteration 3 read started === collection.length, ran nothing, and returned SUCCESS with summary.failed = 0. ⚠️ Read that measurement for the MECHANISM: the shape it was taken on is a map, which this parse rule deliberately does not reach — making the run-time refusal of a region-contained node that durably suspends LOUD is the second half of ruling D and ships as its own domain:services change. ⛔ NOT losslessly convertible: hoisting a node out of a region is a GRAPH REWRITE — new edges, a changed exit, sometimes a deleted container — and which of several shapes the author meant is an intent no artifact records, so a transform that picked one would be inventing the design. That leaves D3, a structured TODO naming each node to edit. ⚠️ Two further boundaries this refusal deliberately does NOT reach, because a parse cannot: a pausing node type contributed by a PLUGIN (ADR-0018 left the node-type namespace open and a parse has no registry), and a region nested past MAX_REGION_DEPTH (32), where the walk stops. For both, the engine's run-time refusal is still the only one — unchanged by this step, not fixed by it.
    • Done when: No screen / wait / approval / approval_revise node and no end node sits inside any loop body, parallel branch or try_catch try/catch region in the stack, at any depth. FlowSchema.parse (and therefore defineFlow, registerFlow, os validate and a Studio publish) accepts the stack: a node still nested is refused with the node AND the region named in one message (A \approval` node may not sit inside a structured region — `loop 'sweep' body → try_catch 'guard' try` is a region body …), anchored at nodes[i].config.body.nodes[j].typeso a designer can jump to it. ⛔ A region-nestedmaporsubflowis NOT part of this migration and needs no edit to load — if such a flow reportssuccesshaving processed nothing, that is the run-time half of the same ruling and not a stack edit. Behaviour to re-check after editing, because the fix CHANGES IT deliberately: a region-nestedendwas a no-op, so moving it to the top level makes the run actually TERMINATE there — check that the nodes after the container were not relying on continuing past it. A hoistedapproval/wait/screen` now parks the run where the enclosing graph can see it, so anything that polled for the sweep to finish sees a suspended run instead of a green-but-empty one.
  • sys-account-issuer-retired — ``sys_account.issuer— the column, its{ fields: ['issuer', 'account_id'], unique: true }index, its label in the four generated translation bundles, and the@objectstack/plugin-auth symbols that existed only to serve it (backfillAccountIssuer, CREDENTIAL_ISSUER, oauthIssuerFor, ResolvedSocialProvider, BackfillAccountIssuerOptions, BackfillAccountIssuerResult). The accounts.list()client type losesissuer with the route that stopped returning it. → nothing — account identity is (provider_id, account_id), which sys_account has declared UNIQUE since the object was created. A caller that read account.issuer reads nothing in its place: the authority is sys_sso_provider.issuer, resolved through the account's provider_id, which is unique per environment. A host that called backfillAccountIssuer on its own schedule deletes the call; there is no successor pass. Existing deployments run the ceremony below before the column is dropped.
    • Why not automatic: better-auth 1.7.3 removed the issuer-scoped account identity outright: createLocalAccountIssuer is deleted, accountSchema.issuer is gone, AccountKey is (providerId, accountId) again, and the account.issuer column and its unique index are gone from get-tables. There is no drop-in replacement. Maintainer ruling 2026-09-10: adopt the rollback rather than own a fork of an identity model the vendor abandoned — a permanent fork on the authentication library was refused, and staying pinned was refused as the durable answer (the exact pin to 1.7.2, taken after a floating 1.7.3 broke a fresh seeded boot, was the stopgap and has done its job). The column was a net liability in its own right: a credential row whose issuer was not the local credential issuer was invisible to findAccountByKey, so sign-in failed INVALID_EMAIL_OR_PASSWORD behind a "User not found" warn pointing at the sys_user row rather than at the account — four checklist items rediscovered that independently. Its discriminating power here was near zero: sys_sso_provider declares { fields: ['provider_id'], unique: 'global' }, so provider_id → issuer is a function within an environment.
    • Done when: BEFORE the column is dropped, os migrate account-issuer reads zero on the deployment: no (provider_id, account_id) key is held by more than one row. That pre-flight reads ROWS, never the index declaration, because syncDeclaredIndexes logs a plain UNIQUE whose CREATE failed on existing duplicates and lets the boot continue (a plain unique over duplicate rows was made loud and non-fatal, the MySQL hash-shadow arm included) — so a database can carry the declaration without the constraint, and on such a database the drop degrades SILENTLY rather than failing. A dirty read refuses; so does a read that throws or a scan that truncates. os migrate apply --allow-destructive re-runs the same pre-flight and refuses the drop before writing any DDL; the boot refusal on unapplied destructive drift is unchanged, so a runtime never auto-migrates. Colliding rows are resolved by the operator — keep the row whose provider account is live, delete the rest so a fresh sign-in re-links — never merged or dropped by the platform. AFTER the drop, a fresh install and an existing-data upgrade both sign in over the real auth route. A provider_id re-pointed at a different IdP must have its account bindings REBUILT: no column records which IdP vouched for a row, so the key cannot separate the old IdP's subjects from the new one's, and the sys_sso_provider update door refuses an issuer change while accounts are still bound to that provider.
  • sys-audit-log-organization-column-retired — sys_audit_log.organization_id — the injected organization column left the compliance ledger (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts, which now declares systemFields.tenant false); the organization a row is about stays in the attribution field tenant_id, and an organization reader is scoped on it by a platform row policy → sys_audit_log.tenant_id, the attribution field every writer stamps. Rewrite any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_audit_log to name tenant_id. A row about a deployment-level action leaves it empty. Under an organization wall an organization reader is scoped to the rows about its active organization by the platform row policy sys_audit_log_org, and a platform administrator reads every row
    • Why not automatic: ADR-0131 D7: the audit ledger may hold rows about deployment-level actions, so the organization a row is about becomes a plain attribution field under a name the tenant-field resolver does not claim, never the tenancy anchor, and the object is governed by object permission, not by the wall. Writer census at commit 3ae59661dc of this repository's main branch: the record mirror, the record-view writer and the sign-in writer in plugin-audit, and the settings change writer in service-settings, stamp tenant_id and stamped the injected column with the same value; the platform-admin standing writer in plugin-security stamps both NULL, by ruling; the two administrative user writers in plugin-auth stamp neither. So the attribution field already carries every organization the column did. Under a walled posture the tenant wall compared the column to the caller organization, which hid every row about no organization from every reader, platform administrators included. The read scope moves to the security layer, where the engine computes it once: the platform row policy tenant_id equal to the caller organization, shipped in organization_admin, member_default and viewer_readonly and stripped when no wall is enforced, plus an explicit organization_admin entry for the ledger without viewAllRecords or modifyAllRecords, because the wildcard superuser bypass would otherwise skip the policy on an object with no tenant column and hand each organization administrator every organization's rows. Per-tenant retention windows partition on tenant_id. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; once its values are confirmed equal to tenant_id, the operator drops it with os migrate apply --allow-destructive, and any row where they differ is reported rather than dropped.
    • Done when: No authored metadata names organization_id on sys_audit_log: the field resolver (lint and the data door) answers it as an unknown field. A row about a deployment-level action is written with tenant_id empty and no refusal. Under an organization wall an organization administrator lists the rows whose tenant_id is its active organization and no other, and a platform administrator lists every row, the rows with no tenant_id included. Under single the policy is stripped and the organization administrator lists every row, as before.
  • sys-flow-dispatch-organization-column-retired — sys_flow_dispatch.organization_id — the injected organization column left the flow trigger dispatch claim ledger (packages/services/service-automation/src/sys-flow-dispatch.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_flow_dispatch is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_flow_dispatch. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is ObjectStoreFlowDispatchStore in @objectstack/service-automation: two write sites (the claim insert and the settle update), each under a system context whose row is a dispatch key and its outcome, naming no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_flow_dispatch: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_flow_dispatch, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-job-organization-column-retired — sys_job.organization_id — the injected organization column left the platform background-job catalogue (packages/platform-objects/src/audit/sys-job.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_job is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_job. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: four write sites (the update and insert arms of the schedule upsert, the active toggle and the run summary bump), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_job: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_job, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-job-queue-organization-column-retired — sys_job_queue.organization_id — the injected organization column left the durable job and message queue (packages/platform-objects/src/audit/sys-job-queue.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_job_queue is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_job_queue. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbQueueAdapter in @objectstack/service-queue: nine write sites (the publish insert and the worker update and delete paths), each under a system context whose row literal names no organization. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_job_queue: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_job_queue, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-job-run-organization-column-retired — sys_job_run.organization_id — the injected organization column left the platform job run history (packages/platform-objects/src/audit/sys-job-run.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_job_run is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_job_run. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is DbJobAdapter in @objectstack/service-job: two write sites (the run start insert and the run finish update), each under a system context whose row literal names no organization, including for a job that declares the organization it runs as, whose stamp reaches the job data writes and never this ledger. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_job_run: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_job_run, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-migration-journal-organization-column-retired — sys_migration_journal.organization_id — the injected organization column left the migration run journal (packages/platform-objects/src/system/sys-migration-journal.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_migration_journal is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_migration_journal. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: the sole writer is the @objectstack/core migration runner: one append site, under a system context or under the transaction it opened with one, and the row contract MigrationJournalEventSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_migration_journal: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_migration_journal, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-migration-organization-column-retired — sys_migration.organization_id — the injected organization column left the deployment data-migration flag ledger (packages/platform-objects/src/system/sys-migration.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_migration is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_migration. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: eleven write sites in six files (the platform-objects migration flag helpers, the ObjectQL lax-deviation and boot-admission revocation writes, and the seed-tenancy, membership-backfill and flow-credential receipts), each under a system context, and the row contract DataMigrationFlagSchema has no organization field to carry. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_migration: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_migration, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-presence-organization-column-retired — sys_presence.organization_id — the injected organization column left the realtime presence table (packages/services/service-realtime/src/objects/sys-presence.object.ts, which now declares systemFields.tenant false), and reading the table now requires the manage_platform_settings capability → nothing on this table — sys_presence is deployment-level state (ADR-0131 D7) and no organization owns a row. Delete any authored filter, list-view column, report grouping, formula or seed key that names organization_id on sys_presence. A principal that must read the table needs the manage_platform_settings capability, which platform administrators hold
    • Why not automatic: ADR-0131 D7: a table whose rows no writer attributes to an organization is deployment-level and loses the column; the writer decides membership, not the name. Writer census at commit e67ba80049 of this repository's main branch: nothing writes the table through ObjectQL at all (presence travels the realtime path, and the generic data door exposes reads only), and a person present in several organizations is one person. So the injected column only ever held NULL. The census is the same procedure that reports the organization-stamping writers of sys_http_delivery, sys_secret and sys_email, so it can fire. Under a walled posture the tenant wall compared that NULL to the caller organization and hid every row from every reader, platform administrators included; with no column there is no wall, and the table is governed by object permission instead (D7). The capability gate is part of the same change, not a follow-up: the organization_admin grant carries the superuser bits on every object, so without it a walled deployment would hand each organization administrator every other organization's rows. Existing databases: schema sync is additive, so the physical column stays and the boot drift report names it orphaned; by the census it holds only NULL, so dropping it loses nothing. The operator drops it with os migrate apply --allow-destructive, the remedy the drift report names.
    • Done when: No authored metadata names organization_id on sys_presence: the field resolver (lint and the data door) now answers it as an unknown field. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a read of sys_presence, and a platform administrator lists every row with no organization filter. After os migrate apply --allow-destructive the boot no longer reports the orphaned column.
  • sys-setting-global-rung-moved — sys_setting.scope global — the settings cascade global rung left the tenant-scoped settings table: a value for a key declared at global scope is stored in the new tenant-less object sys_platform_setting (packages/platform-objects/src/system/sys-platform-setting.object.ts), and the global option of sys_setting.scope is retired → sys_platform_setting, one row per (namespace, key) for the deployment, with the same value, value_enc, encrypted, locked, locked_reason and updated_by columns and no scope, user_id or organization_id. Write it only through the settings door (/api/settings/:namespace), which routes a global-scope key there. Reading it through the generic data API requires the manage_platform_settings capability. Delete any authored filter, list-view column or seed that names scope = global on sys_setting
    • Why not automatic: ADR-0131 D7: deployment-level runtime settings leave the tenant-scoped table, and a tenant-less object holds the values an operator must change without a restart. A census of every manifest at commit 51290bca2c of this repository's main branch found seven namespaces whose keys sit at the global rung (ai, auth, knowledge, mail, sms, storage and the ObjectQL lifecycle defaults), every one edited live in Setup, so none of them moves to boot configuration. The settings service is the only writer of a global row, it writes under a system context, and the row names no organization, so on sys_setting the injected organization column only ever held NULL there and a walled posture hid the row from every reader. The resolver reads the rung from the new object alone and excludes scope global from its sys_setting reads, so a row a pre-v18 database still holds there is not a second source; no write path produces one any more, which is why the select option retires rather than staying a declared value no write can reach. The cascade order, the lock semantics, SpecifierScopeSchema and the global resolution source are unchanged. An encrypted value moves without re-encryption: the ADR-0128 AAD binds the settings scope, namespace and key, never the holder object or an organization, so a sys_secret handle copied into the new row opens as it did. Existing databases: nothing moves automatically (ADR-0131 D14). The v18 upgrade ceremony moves each sys_setting row at scope global into sys_platform_setting by namespace and key, value_enc handle included; until it runs, those values read as their next rung or the manifest default.
    • Done when: A write of a global-scope settings key creates or updates exactly one sys_platform_setting row for its (namespace, key) and no sys_setting row, and a read of the key answers that value with source global. A sys_setting row still at scope = global is not answered by any read. An encrypted global value keeps its sys_secret handle in sys_platform_setting.value_enc and opens. On every tenancy posture a principal without manage_platform_settings is refused 403 PERMISSION_DENIED on a generic data read of sys_platform_setting, while the settings door keeps answering it for a holder of the manifest capability. After the v18 ceremony no sys_setting row is at scope = global.
  • sys-view-definition-retired — the sys_view_definition platform object (SysViewDefinitionObject, exported by @objectstack/metadata-core and re-exported by @objectstack/platform-objects and its metadata subpath), its registration by MetadataPlugin and by the metadata protocol assembly, its name in PLATFORM_OBJECTS_BY_PACKAGE (@objectstack/spec system constants), its kernel:ready active-row index migration and that migration's exports from @objectstack/metadata-protocol (ensureViewDefinitionActiveIndex, resolveIndexExec, buildActiveIndexSql, VIEW_DEFINITION_TABLE, VIEW_ACTIVE_INDEX_NAME, VIEW_ACTIVE_PROBE_INDEX_NAME, VIEW_ACTIVE_INDEX_COLUMNS and the EnsureViewIndex types), and its idx_sys_view_def_active entry in the os migrate duplicates runtime-index pre-flight → nothing replaces the table — a runtime-authored view is a view metadata item in sys_metadata, written through PUT /api/v1/meta/view/<name> (the client's meta.saveItem for type view), which is what every framework and Studio view door already does. Delete any import of the removed symbols; classifyIndexFailure and the IndexExec type are still exported by @objectstack/metadata-protocol, from the shared index-migration module. A stack that names sys_view_definition (a lookup target, a flow trigger, a permission entry, a platform-global declaration) removes the reference: the name no longer resolves to a platform object
    • Why not automatic: ADR-0131 D13: an object no framework code writes or reads is inert and retires. Census at commit 41d0d4038c of this repository's main branch, run with the glob pathspec over packages/**/src (41 files; control word sys_metadata 769) and repo-wide (65 files): no framework writer of the table's rows and no reader of them — the only statements that touched its rows were the active-row index migration's own presence and duplicate probes and the os migrate duplicates pre-flight's copy of the latter. The sibling Studio repository never referenced it (0 hits against 93 for sys_metadata, at its main branch and at the pinned console commit): its view create, update and list doors write the ADR-0005 view overlay through the metadata API. The only way a row could ever have reached the table was a caller using the generic data door on the object by name. Keeping it registered kept an API-enabled table, a boot-time index migration and a pre-flight probe alive for no consumer, and kept the name resolving as a real platform object for authored metadata that named it.
    • Done when: No code imports SysViewDefinitionObject or the removed metadata-protocol exports (TS2305 after upgrade). isPlatformProvidedObjectName answers false for sys_view_definition, so a stack referencing the name is flagged as a probable typo rather than resolved. Neither MetadataPlugin nor the metadata protocol assembly registers the object, a serving boot issues no statement naming it, and os migrate duplicates reports three runtime-index pre-flight entries, none naming it. Existing databases: schema sync is additive and never drops a table, so a database an earlier release provisioned keeps sys_view_definition and any rows a caller wrote through the generic data door, and nothing reads them. os migrate apply --allow-destructive does not drop it either — it reconciles declared objects only (measured: on one database it dropped an orphaned column of a declared table and left this table and its row in place). os migrate plan lists it among the platform-prefixed tables nothing declares when the project has a host config. Export any row worth keeping; dropping the table is the operator's call, by hand.
  • system-cache-durations-unit-in-key — the two cache durations whose name carried no unit: CacheTier.ttl and CacheAvalanchePrevention.circuitBreaker.resetTimeout (system/cache.zod.ts) → ttlSeconds and resetTimeoutSeconds — rename each key; both values, the 300 TTL default and the 30 reset default are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. These two are one entry because they are one file and one authoring session: a cache tier and the avalanche-prevention block that protects it. Each sat beside a number in a DIFFERENT unit with nothing at the authoring site to separate them — CacheTier.ttl (seconds) beside maxSize (megabytes), and circuitBreaker.resetTimeout (seconds) beside lockout.lockTimeoutMs (milliseconds) on the very same schema. That last pair is the sharpest case on this file: one shape already carried both conventions, and the suffixed one was the honest half. Both are retiredKey() tombstones; neither shape is strict, so a bare deletion would strip in silence and the unknown-key error could not carry the rename. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no cache collection, and neither a cache tier nor an avalanche-prevention block is a registered metadata kind stored as a sys_metadata row, so the conversion chain has no seam that would see one. ADR-0087.
    • Done when: Every author of a CacheTier spells ttlSeconds and every author of a CacheAvalanchePrevention spells circuitBreaker.resetTimeoutSeconds. Authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription rather than a bare unrecognized-key error. Behaviour is unchanged: a tier given ttlSeconds: 600 expires after ten minutes exactly as ttl: 600 did, an omitted key still defaults to 300, and resetTimeoutSeconds still defaults to 30. One thing this rename deliberately does NOT touch: lockout.lockTimeoutMs keeps its name and its MILLISECOND unit — the two timeouts on this schema were never the same unit and must not be migrated as if they were.
  • system-collaboration-durations-unit-in-key — the two collaboration-session durations whose name carried no unit: CollaborationSessionConfig.idleTimeout and CollaborationSessionConfig.snapshot.interval (system/collaboration.zod.ts) → idleTimeoutMs and snapshot.intervalMs — rename each key; both values and the 300000 idle-timeout default are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. idleTimeout is the collision that got this whole population ruled rather than merely noted: it is MILLISECONDS here, while the tenant surface carried its own idleTimeout in SECONDS at the same time — so the identical bare name meant five minutes on one shape and three and a half days on the other, a 1000x divergence no parse could catch because both readings are positive integers. The tenant half was already renamed, in the same change that landed the duration gate itself; this is the half that remained. snapshot.interval rides in the same entry because it is the same object graph and the same authoring session — leaving one bare beside the other would have preserved exactly the ambiguity the rename removes. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no collaboration collection, and a session config is a runtime call argument rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087.
    • Done when: Every caller that opens a collaboration session spells idleTimeoutMs, and every snapshot block spells intervalMs. Authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: idleTimeoutMs: 600000 idles out after ten minutes exactly as idleTimeout: 600000 did, an omitted key still defaults to 300000, and the positive-integer bounds ride along with the renamed keys so a zero or negative interval is still refused. The migration is proved correct when no source in the tree spells a bare idleTimeout on ANY shape — the seconds-valued tenant twin is already gone, so a surviving bare spelling is now unambiguously a missed edit rather than the other key.
  • system-failover-health-check-interval-unit-in-key — FailoverConfig.healthCheckInterval, the disaster-recovery health-check period whose name carried no unit (system/disaster-recovery.zod.ts) → healthCheckIntervalSeconds — rename the key; the value and the 30 default are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because its file has exactly one offender left — and because the key directly beside it is the counter-example that shows where the line falls. FailoverConfig.dns.ttl is also a bare-named duration in seconds, and it is NOT renamed: it carries an externalVocabulary marker because it mirrors the DNS resource-record TTL field (RFC 1035 section 4.1.3), spelled ttl by every provider API the value is forwarded to (Route 53, Cloudflare). healthCheckInterval mirrors nothing outside this repo, so the exemption does not reach it. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no disasterRecovery collection and a failover config is host configuration, never a stored sys_metadata row. ADR-0087.
    • Done when: Every FailoverConfig author spells healthCheckIntervalSeconds; authoring healthCheckInterval fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: healthCheckIntervalSeconds: 30 probes every thirty seconds exactly as before, and an omitted key still defaults to 30. The migration is proved correct when dns.ttl is still spelled ttl — a sweep that renamed it too has over-applied the rule and stripped a declared exemption.
  • system-metrics-jsdoc-durations-unit-in-key — the five remaining metrics durations whose unit lived in a source JSDoc only: MetricDefinition.summary.maxAge, ServiceLevelObjective.errorBudget.burnRateWindows[].window, MetricExportConfig.interval, MetricsConfig.collectionInterval and MetricsConfig.retention.period (system/metrics.zod.ts) → summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged
    • Why not automatic: This entry FINISHES what system-metrics-window-durations-unit-in-key started on this file, and the two are meant to be read as a sequence — this one does not amend that record, which stays a true account of what the system-directory duration round did. That round renamed the three metrics window and period lengths whose describe named no unit, and recorded that the error-budget burn-rate window was "outside this rename, not outside the gate population", naming the JSDoc-channel gap as where it would be settled. That gap is now ruled and this is its remediation: director-seat ruling A, 2026-09-11, carrying the maintainer's 「同意」, which keeps the refusal of a duration key whose JSDoc names a unit its describe does not, remediates the 21-row JSDoc-channel population per file, and lands that widened gate last, into a tree already clean. ⚠️ One consequence for readers of the older entry: its acceptanceCriteria says the burn-rate window keeps its name and that a sweep renaming it has over-applied the rule. That sentence was true of that round and is superseded here, by the ruling it itself pointed at; the other key it names, the exporter batch size, is a COUNT of records and still does not move. All five keys here share one defect: the unit (seconds) was stated in the JSDoc above the key, a channel check:duration-unit-keys does not read — it reads .describe() and .meta({ description }) — and four of the five carried no describe at all while the fifth read "Window size". So the reader who most needs the unit, the reader of the published reference page, got a bare integer: 600, 3600, 60, 15 and 604800 are each a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Each key is renamed and its describe corrected in the same stroke, because under the duration-unit rule (the unit lives in the key name or a unit-carrying value, never in the describe prose alone) moving the unit into the describe alone is itself a violation. Three of the five spellings are not the mechanical suffix, and each departure has a reason this file already supplied: burnRateWindows[].window becomes durationSeconds, not windowSeconds, because the enclosing array is already called burnRateWindows so the key would stutter — the objection the system-directory round recorded against window.windowSeconds — and because on this tree windowSeconds is not an authorable key at all, its only key-position occurrence being an alias-map entry in ServerRateLimitConfigSchema that maps the spelling AWAY to windowMs; retention.period becomes durationSeconds, not periodSeconds, because period is calendar vocabulary elsewhere in this spec (ServiceLevelObjective.period.type selects rolling or calendar, PluginRegistryEntry.pricing.billingPeriod is monthly or yearly) so periodSeconds would keep the ambiguous half of the name; and collectionInterval keeps its qualifier as collectionIntervalSeconds so it stays distinct from the MetricExportConfig.intervalSeconds this same card creates one def over. The two mechanical spellings are attested: maxAgeSeconds is the token AccessControlConfig.maxAgeSeconds already carries after this same rule renamed it on system/object-storage.zod.ts, and it keeps the age stem the sibling ageBuckets counts buckets of; intervalSeconds is the token four seconds-valued cadences already carry. Counted in key position across packages/spec/src at fc28c1d38, the base of this change, the seconds suffixes run Seconds 40, Sec 1 (maxExecutionTimeSec) and S 0 — the two bare S keys on that corpus, maxCommitTimeMS and enableRLS, are a millisecond spelling and a boolean — so Seconds is the family; this change takes Seconds to 45 at 9b62f54671. All five are retiredKey() tombstones; none of the five enclosing shapes is strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of a metric definition, an SLO, an export config or a metrics config is a registered metadata kind stored as a sys_metadata row — the same reading the system-directory round recorded for the three keys it renamed. Measured on fc28c1d38: no in-repo code consumer reads any of the five — outside packages/spec the only occurrences of every distinctive key on these shapes (burnRateWindows, errorBudget, downsampling, collectionInterval, cardinalityLimits, maxLabelCombinations, ageBuckets) are in the generated content/docs/references/system/metrics.mdx, which this rename regenerates, against a lit control of 1195 defineStack occurrences on that same corpus at fc28c1d38 (1195 again at 9b62f54671); and the objectui checkout this repo builds against — this is the pin, .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8351 tracked files at that sha, against lit controls window 4470, timeout 1694, period 249, interval 213 and metrics 456 on that same corpus and sha (0 across 8281, against 4449 / 1674 / 249 / 213 / 455, at 47b1f0bb7, 0 across 8234, against 4430 / 1658 / 249 / 213 / 404, at f0268ad78, 0 across 7754, against 4255 / 1431 / 247 / 200 / 401, at a58626c88, 0 across 7650, against 4194 / 1360 / 238 / 195 / 401, at 0abd4f9f8, 0 across 7632, against 4193 / 1360 / 238 / 195 / 401, at 9dfaca654, 0 across 7579, against 4175 / 1351 / 238 / 195 / 374, at 2e818d0b5, 0 across 10267, against 4044 / 1348 / 231 / 196 / 354, at ab1879721, 0 across 10071, against 4002 / 1331 / 231 / 196 / 355, at 89cad75d5, 0 across 9912, against 3916 / 1303 / 234 / 196 / 354, at 31971ff1e, 0 across 9800, against 3873 / 1293 / 233 / 196 / 352, at e420df310, 0 across 9546, against 3772 / 1197 / 228 / 196 / 341, at db11afd49, 0 across 9283, against 3681 / 1172 / 183 / 176 / 340, at dd3f7e1be, 0 across 8512, against 3581 / 1096 / 171 / 179 / 326, at f8a9d0fb0, and 0 across 8303, against 3526 / 1086 / 170 / 179 / 324, at 62597c588), so no pin bump is owed. ADR-0087.
    • Done when: Every metric definition spells summary.maxAgeSeconds, every error-budget burn rate window spells durationSeconds, every metric export config spells intervalSeconds, and every metrics config spells collectionIntervalSeconds and retention.durationSeconds. Authoring any of the five old spellings fails to compile (input type never) and fails to parse with the rename prescription naming the suffixed key — not an unrecognized_keys issue. Behaviour is unchanged: collectionIntervalSeconds: 15 collects every fifteen seconds exactly as collectionInterval: 15 did, and every default (600, 60, 15, 604800) and positive-integer bound rides along with its renamed key. Each new describe names the unit, so the reference page carries it. Verify the same-named decoys on this one file apart: MetricAggregationConfig.window and ServiceLevelIndicator.window are objects that already hold a durationSeconds of their own, and ServiceLevelObjective.period is an object holding a durationSeconds and a calendar — none of the three moves, and a sweep that renamed any of them has over-applied this rule.
  • system-metrics-window-durations-unit-in-key — the three metrics window/period lengths whose name carried no unit: MetricAggregationConfig.window.size, ServiceLevelIndicator.window.size and ServiceLevelObjective.period.duration (system/metrics.zod.ts) → window.durationSeconds, window.durationSeconds and period.durationSeconds — rename each key; every value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one measurement expressed three times on one file: how long a window or period is. The new name is deliberately NOT the mechanical sizeSeconds the gate prints. size means a byte or row count everywhere else in this spec — CacheTier.maxSize is megabytes, RegistryConfig.cache.maxSize is bytes, and this very file spells a batch row count size — so sizeSeconds would have kept the misleading half of the name and bolted a unit onto it, leaving a reader to decide whether a window is measured in bytes-per-second or in time. windowSeconds was rejected for a plainer reason: the parent key is already window, so it would read window.windowSeconds. durationSeconds names what the number IS, and the file itself supplied the precedent — ServiceLevelObjective.period already called its length a duration, so after the rename all three read alike instead of one borrowing byte vocabulary. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no metrics collection, and none of an aggregation config, an SLI or an SLO is a registered metadata kind stored as a sys_metadata row. ADR-0087.
    • Done when: Every aggregation window and SLI window spells durationSeconds, and every SLO period spells durationSeconds. Authoring window.size or period.duration fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: window.durationSeconds: 300 aggregates over five minutes exactly as size: 300 did, and the positive-integer bounds ride along with the renamed keys. Two keys on this same file deliberately do NOT move, and a sweep that renamed either has over-applied the rule: the error-budget burn-rate window names its unit only in the JSDoc above it ("Window size in seconds"), a channel the gate does not read: it reads .describe() and .meta({ description }), and that key's describe ("Window size") names none. So the gate lists it among the duration-shaped keys without judging it — neither an offender nor an exemption — and it is outside this rename, not outside the gate population; that JSDoc-channel gap is filed as a finding of its own; and the exporter batch size is a COUNT of records, not a duration, so it has no unit to carry. Both keep their names. One of those two moves after all, in this same protocol step: the error-budget burn-rate window is renamed to durationSeconds by system-metrics-jsdoc-durations-unit-in-key, the remediation of the JSDoc-channel gap named just above (ruled: a duration key whose JSDoc names a unit its describe does not is refused). Read that entry with this one; the exporter batch size is still a COUNT of records and still does not move.
  • system-object-storage-durations-unit-in-key — the two object-storage durations whose name carried no unit: AccessControlConfig.maxAge and StorageConnection.timeout (system/object-storage.zod.ts) → maxAgeSeconds and timeoutMs — rename each key; both values are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. AccessControlConfig.maxAge is the one key in this stack where the two structural exemptions and the rename look alike from a distance, so the reasoning is recorded rather than assumed. It was CONSIDERED for an externalVocabulary marker and demoted on evidence: every bucket-CORS standard the value is forwarded to spells the field WITH its unit — S3 MaxAgeSeconds, GCS maxAgeSeconds, Azure MaxAgeInSeconds — so marking it would have exempted a DEVIATION from the cited standard rather than a mirror of it, which is the opposite of what the marker declares. Its twin shared/CorsConfig.maxAge DID get the marker and keeps its bare name, because the Fetch response header that one mirrors, Access-Control-Max-Age, genuinely carries no unit token. Two maxAge keys on opposite sides of the same line; the asymmetry is the point and must not be harmonised. StorageConnection.timeout rides along as the plain case on the same file. Both are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no objectStorage collection, and neither shape is a registered metadata kind stored as a sys_metadata row. ADR-0087.
    • Done when: Every bucket access-control block spells maxAgeSeconds and every storage connection spells timeoutMs. Authoring either old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: maxAgeSeconds: 3600 caches a preflight for an hour exactly as maxAge: 3600 did, and the non-negative bounds ride along with the renamed keys. The migration is proved correct when shared/CorsConfig.maxAge is STILL spelled maxAge — a find-and-replace that renamed both has destroyed a declared external-vocabulary mirror, and the gate will not catch it because the marker exempts the key either way.
  • system-registry-config-durations-unit-in-key — the three package-registry durations whose name carried no unit: RegistryUpstream.syncInterval, RegistryUpstream.timeout and RegistryConfig.cache.ttl (system/registry-config.zod.ts) → syncIntervalSeconds, timeoutMs and cache.ttlSeconds — rename each key; every value, the 30000 timeout default and the 3600 TTL default are unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. The three are one entry because they are one file and, for the first two, one object: RegistryUpstream declared a SECONDS interval and a MILLISECONDS timeout twenty-five lines apart, both bare. That pair carries the clearest demonstration in this card of why a bound is no substitute for a name — timeout is min(1000), which reads as one second under the right unit and as sixteen minutes under the wrong one, and both readings satisfy the validator. The cache TTL is the same defect one schema over, beside a maxSize measured in bytes. All three are retiredKey() tombstones; the shapes are not strict, so a bare deletion would strip in silence. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no registry collection, and a registry config is host configuration read at startup rather than a stored sys_metadata row, so the conversion chain has no seam that would see it. ADR-0087.
    • Done when: Every upstream declaration spells syncIntervalSeconds and timeoutMs, and every registry cache block spells ttlSeconds. Authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged: syncIntervalSeconds: 300 syncs every five minutes exactly as syncInterval: 300 did, an omitted timeoutMs still defaults to 30000, an omitted ttlSeconds still defaults to 3600, and the min-60 / min-1000 / min-0 bounds ride along with the renamed keys so a too-small interval or timeout is still refused. The pair on RegistryUpstream is the one to check by hand rather than by search-and-replace: after the migration a reader can tell at the authoring site that 300 and 30000 are not the same kind of number.
  • system-tracing-otel-exporter-durations-unit-in-key — the four tracing-configuration durations whose unit lived in a source JSDoc only: OpenTelemetryCompatibility.exporter.timeout, OpenTelemetryCompatibility.exporter.batch.exportTimeout, OpenTelemetryCompatibility.exporter.batch.scheduledDelay and TracingConfig.performance.exportInterval (system/tracing.zod.ts) → timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged
    • Why not automatic: Director-seat ruling A of 2026-09-11 on the JSDoc-channel finding, carrying the maintainer's 「同意」: a duration key whose JSDoc names a unit its describe does not is refused, and the keys in that shape are remediated per file before that refusal lands — the duration-unit rule (the unit lives in the key name or its value type, never in prose alone) executed file by file. It follows system-tracing-span-duration-unit-in-key on this same file and does not amend it: that entry retired Span.duration under ruling B, whose population was the describe channel, and these four keys were never in it — they are the JSDoc-only channel that finding opened, which is why one file carries two rounds. Each key named milliseconds in its JSDoc — "Timeout in milliseconds", "Export timeout in milliseconds", "Scheduled delay in milliseconds", "Background export interval in milliseconds" — and the JSDoc above a key is NOT what content/docs/references/** renders; .describe() is. Measured on this tree: all four carried NO .describe() at all, so the published reference row for each was a bare integer with no unit anywhere on the page — a strictly worse channel than the unit-in-prose shape the duration-unit rule already refuses, since here the reference reader had no prose to misread. The magnitudes make the guess plausible in both directions: 10000, 30000, 5000 and 5000 are all defensible as seconds and as milliseconds, and an operator who reads seconds sets an exporter deadline 1000x short. The suffix is the family spelling, counted in key position at 98bd7986fe over packages/spec/src *.ts (reproduce with git grep -hoE on that ref): 281 *Ms declarations over 42 distinct names, timeoutMs 65 of them and intervalMs 14, against 0 key-position timeoutSeconds and 77 *Seconds of any name; the Delay-plus-Ms pairing is likewise already attested on that same ref (maxDelayMs 9, initialDelayMs 9, maxRetryDelayMs 5, debounceDelayMs 2, delayMs 2, retryDelayMs 1) with 0 occurrences of any competing exportTimeout, scheduledDelay or exportInterval spelling, suffixed or Seconds. Note this file is milliseconds throughout and its own landed precedent is Span.duration to durationMs, the opposite of the sibling metrics card whose rows were seconds. exporter.timeoutMs and exporter.batch.exportTimeoutMs are deliberately allowed to sit one nesting level apart: the pair pre-exists the rename — the batch sub-object is the OpenTelemetry batch span processor's own four knobs (max batch size, max queue size, scheduled delay, export timeout) beside the exporter's own request deadline — so renaming either to something more distinctive would depart from the vocabulary the shape mirrors, and the nesting already disambiguates every read point (exporter.timeoutMs vs exporter.batch.exportTimeoutMs). All four old spellings are retiredKey() tombstones: neither OpenTelemetryCompatibilitySchema nor TracingConfigSchema nor any object nested inside them is .strict(), so a bare deletion would be a SILENT STRIP (ADR-0104; an earlier field-key prune measured exactly that — the parse succeeded and the removed key was dropped without a word) — and the stripped value lands on an export deadline and a background export period. Why a semantic entry and not a D2 conversion: the conversion chain walks a normalized STACK, and neither def is an authorable surface — stack.zod.ts declares no tracing collection, no metadata-type binding or manifest embed carries either, and a tracing configuration is never a stored sys_metadata row — so a conversion would be a transform with no seam that ever runs. That is the same disposition system-tracing-span-duration-unit-in-key recorded for the other key on this file. Measured at 98bd7986fe: NO in-repo reader exists outside packages/spec — OpenTelemetryCompatibility, TracingConfig and all three batch key names occur 0 times across the whole tree at that ref excluding packages/spec and content/docs/references, against a lit control of 18920 Schema occurrences on exactly that corpus and ref — both counts from one git grep -o over 98bd7986fe with those two pathspec exclusions — and a dark control of 0; inside packages/spec the only occurrences are tracing.zod.ts, its test, and the generated rows in content/docs/references/system/tracing.mdx, which this rename regenerates. And the pinned objectui checkout — .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6 — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8351 files tracked at that sha (the 517 Span and 57 SpanSchema hits are objectui's own HTML text-span component, TextSpanSchema, an unrelated name, plus colSpan and prose), against two lit controls on that same corpus and sha: 18047 hits for the bare token objectstack, and 7545 for the package specifier @objectstack/spec (at 47b1f0bb7: 0 across 8281, Span 517, 17980 and 7523; at f0268ad78: 0 across 8234, Span 517, 17956 and 7522; at a58626c88: 0 across 7754, Span 509, 17468 and 7246; at 0abd4f9f8: 0 across 7650, Span 508, 17390 and 7209; at 9dfaca654: 0 across 7632, Span 508, 17313 and 7186; at 2e818d0b5: 0 across 7579, Span 505, 17227 and 7134; at ab1879721: 0 across 10267, Span 491, 16377 and 6665; at 89cad75d5: 0 across 10071, Span 489, 16044 and 6461; at 31971ff1e: 0 across 9912, Span 486, 15691 and 6206; at e420df310: 0 across 9800, Span 486, 15352 and 6024; at db11afd49: 0 across 9546, Span 486, 14704 and 5545; at dd3f7e1be: 0 across 9283, Span 485, 13745 and 5466; at f8a9d0fb0: 0 across 8512, Span 488, 13347 and 5123; at 62597c588: 0 across 8303, Span 486, 13125 and 5043).
    • Done when: Every author and reader of an OpenTelemetryCompatibility spells exporter.timeoutMs, exporter.batch.exportTimeoutMs and exporter.batch.scheduledDelayMs, and every one of a TracingConfig spells performance.exportIntervalMs. Authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription naming the suffixed key — not with a generic unrecognized_keys issue, which these non-strict shapes could never have raised anyway. Behaviour is unchanged: the same milliseconds, the same 10000 / 30000 / 5000 / 5000 defaults and the same int().positive() bounds, and all four published describes now name milliseconds where before there was no describe at all. The authorable-surface and authorable-defaults ledgers move nothing: every one of the four is NESTED, and those artifacts record top-level keys per def only.
  • system-tracing-span-duration-unit-in-key — Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts) → durationMs — rename the key; the value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender on its file and the only one in this card that is a pure runtime-emitted measurement: a span is written by an exporter and read by a backend, never authored by hand. That is also why it is a rename and not an externalVocabulary mirror, which is the exemption a tracing shape would most plausibly claim: OpenTelemetry, whose model this schema follows, carries span length as a start/end nanosecond PAIR and declares no key named duration at all, so there is no external spelling for the marker to point at. The shape already spells its two instants startTime and endTime, so the bare duration was the one measurement on the span that did not say what it was. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and an exporter emitting the old spelling would lose the value without an error. Why a semantic entry and not a D2 conversion: an emitted span is never a stack collection member and never a stored sys_metadata row — the same disposition every runtime-emitted measurement in this stack has taken. ADR-0087.
    • Done when: Every exporter that BUILDS a Span spells durationMs, and every consumer that reads a span length reads durationMs. Authoring duration fails to compile (input type never) and fails to parse with the rename prescription rather than silently dropping the measurement. Behaviour is unchanged: durationMs: 150 is the same 150 milliseconds, and the non-negative bound rides along with the renamed key so a negative span length is still refused. Note the sibling instants startTime and endTime are ISO-8601 strings, not numbers, and are untouched by this rename.
  • system-worker-queue-rate-limit-duration-unit-in-key — QueueConfig.rateLimit.duration, the worker rate-limit window whose name carried no unit (system/worker.zod.ts) → durationMs — rename the key; the value is unchanged
    • Why not automatic: Maintainer ruling B on duration units (2026-09-02, its population widened on 2026-09-05 to every authored and every runtime-emitted duration, bar the exemptions a schema declares on the key itself): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. It stands alone because it is the only offender left on its file, and the file itself is what makes it a drift rather than a convention: TaskResult.durationMs, declared ninety lines earlier in the SAME source, already spelled the identical measurement with its unit. One file, one unit, two spellings, and the correct one was already there — so this rename removes an internal inconsistency rather than imposing an external one. Tombstoned with retiredKey(); the shape is not strict, so a bare deletion would strip in silence and a queue would fall back to no rate limit at all without an error. Why a semantic entry and not a D2 conversion: stack.zod.ts declares jobs, not queues, so a QueueConfig is worker host configuration rather than a stack collection member or a stored sys_metadata row, and the conversion chain has no seam that would see it. ADR-0087.
    • Done when: Every queue declaration spells rateLimit.durationMs. Authoring rateLimit.duration fails to compile (input type never) and fails to parse with the rename prescription rather than silently dropping the window and leaving the queue unthrottled. Behaviour is unchanged: { max: 100, durationMs: 60000 } is a hundred tasks a minute exactly as { max: 100, duration: 60000 } was, and the positive-integer bound rides along with the renamed key. The sibling max is a COUNT and keeps its name — it has no unit to carry.
  • tenant-schema-cache-ttl-unit-in-key — SchemaLevelIsolationStrategy performance.schemaCacheTTL (system/tenant.zod.ts) → performance.schemaCacheTtlSeconds (default 3600) — rename the key; the value (seconds) is unchanged
    • Why not automatic: Maintainer ruling A, 2026-09-11: the gate that reads a duration key's JSDoc lands last, after its offenders are fixed file by file — so this entry executes, per file, the rule that a duration number key carries its unit in its name. The key carried its unit (seconds) in a source JSDoc only — "Schema cache TTL in seconds" — while .describe(), the text content/docs/references/** publishes, said "Schema cache TTL" and named no unit at all. So the reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 3600 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. Under that rule's gate, moving the unit into the describe alone is itself a violation (unit in prose, none in the name), so the key is renamed and the describe is corrected in the same stroke. Spelled Ttl and not TTL: counted on this tree, the suffixed family already spells it that way in every member (cacheTtlSeconds 11, ttlSeconds 3, defaultCacheTtlSeconds 1) and no key-position TtlSeconds variant spells it otherwise. Tombstoned with retiredKey() because the nested performance object is not strict, so a bare deletion would silently strip the key. Why a semantic entry and not a D2 conversion: stack.zod.ts declares no tenancy collection and a tenant isolation strategy is not a stored metadata row (it describes cloud tenancy configuration), so the chain has no seam that runs on it — the same reading tenant-timeouts-unit-in-key recorded for the two sibling keys on this file. Measured on bd25e897dc: no in-repo runtime reads the key — outside packages/spec/src/system/tenant.zod.ts and its test the only occurrences are the four generated rows in content/docs/references/system/tenant.mdx, which this rename regenerates; and the pinned objectui checkout — .objectui-sha = 20c6d351ad74d2b14a93becdc134d51b91b2d2e6 — spells it 0 times across 8351 tracked files, against lit controls TTL 184 and tenant 1340 on the same corpus (0 across 8281, against 184 and 1338, at 47b1f0bb7; 0 across 8234, against 184 and 1338, at f0268ad78; 0 across 7754, against 184 and 1319, at a58626c88; 0 across 7650, against 182 and 1318, at 0abd4f9f8; 0 across 7632, against 182 and 1318, at 9dfaca654; 0 across 7579, against 182 and 1317, at 2e818d0b5; 0 across 10267, against 180 and 1238, at ab1879721; 0 across 10071, against 181 and 1237, at 89cad75d5; 0 across 9912, against 181 and 1237, at 31971ff1e; 0 across 9800, against 181 and 1235, at e420df310; 0 across 9546, against 181 and 1200, at db11afd49; 0 across 9283, against 181 and 1185, at dd3f7e1be; 0 across 8512, against 156 and 1034, at f8a9d0fb0; 0 across 8303, against 156 and 987, at 62597c588).
    • Done when: Every schema-level tenant isolation source spells performance.schemaCacheTtlSeconds; authoring performance.schemaCacheTTL fails to compile and fails to parse with the rename prescription naming the suffixed key; the parsed default is 3600 as before, and the published describe reads "Schema cache TTL in seconds".
  • tenant-timeouts-unit-in-key — DatabaseLevelIsolationStrategy connectionPool.idleTimeout/ TenantSecurityPolicyaccessControl.sessionTimeout (system/tenant.zod.ts) → connectionPool.idleTimeoutSeconds (default 300) and accessControl.sessionTimeoutSeconds (default 3600) — rename each key; the values (seconds) are unchanged
    • Why not automatic: Maintainer ruling 2026-09-02, B: a duration number key carries its unit in its name, enforced by a gate with no grandfathered baseline — folding in the finding that these two descriptions named no unit. Both keys carried their unit (seconds) in a source JSDoc only; .describe() — the text content/docs/references/** publishes — said "Idle pool timeout" and "Session timeout" with no unit at all. So the one reader who most needs the unit, the reader of the published reference page, was the only reader who never saw it: 300 is a plausible number of seconds and a plausible number of milliseconds, and nothing on the page decided it. That finding proposed adding the unit to the two descriptions; under the ruled gate that exact fix is a violation (unit in prose, none in the name), so the keys are renamed instead — one breaking change per key, and the tree never passes through a state the gate refuses. Both are retiredKey tombstones (the nested objects are not strict). Why a semantic entry and not a D2 conversion: neither schema is a stack collection member or a stored row (they describe cloud tenancy configuration), so the chain has no seam that runs on them (the kernel/Manifest:loading precedent). Measured on ca46f8f12: no in-repo runtime reads either key.
    • Done when: Every tenant isolation / security-policy source spells idleTimeoutSeconds and sessionTimeoutSeconds; authoring idleTimeout or sessionTimeout fails to compile and fails to parse with the rename prescription naming the suffixed key; the parsed defaults are 300 and 3600 as before.
  • time-default-zone-refused — a literal defaultValuewith aZor a UTC offset on atimefield, or on an action param typedtime`` → the wall clock itself, HH:MM or HH:MM:SS with no zone, or a datetime field when the value is an instant. The conversion drops a Z or a zero offset, which names the same wall clock. It does not touch a non-zero offset (08:00+08:00): whether that meant 08:00 or the UTC 00:00 only the author knows, so rewrite it by hand
    • Why not automatic: A time value is a zone-less wall clock (ADR-0053 D-C1), and the record validator already refuses a zone-suffixed time of day on write. The stored form still admitted one, so a field default such as 10:00Z parsed clean and every insert that fell back to it was then refused invalid_time on a field the caller never sent, and an action param default or submitted value passed the dispatcher. The stored form now refuses the zone, so the field and action-param default gates refuse it when it is authored and the dispatcher refuses it at submit.
    • Done when: No time field or time action param declares a literal default with a zone. Zone-less defaults, the NOW() token and expression defaults parse as before. A stored sys_metadata row whose default carried a Z or a zero offset loads with the zone dropped; one with a non-zero offset keeps loading as stored, is listed by os migrate meta --stored as a TODO naming the field or param, and fails the schema wherever it is parsed until it is rewritten.
  • time-update-interval-sub-day-retired — ``TimeUpdateInterval— the/analytics/querybody'stimeDimensions[].granularityand an analytics cube dimension'sgranularities[]. The three sub-day members second, minuteandhourare retired;day, week, month, quarterandyear are unchanged and parse byte-identically → the coarsest declared interval that still answers the question — day is the finest bucket the platform labels. A caller who wants raw per-instant rows drops granularity entirely, which groups on the unbucketed timestamp deliberately rather than by accident. There is no mechanical replacement that preserves a sub-day bucket, because no backend ever produced one
    • Why not automatic: ADR-0049 enforce-or-remove — the spec-side narrowing promised by the fix that made driver-memory's analytics face bucket by its declared granularity. The rest of the contract never carried these three: DateGranularity (data/query.zod.ts) — the vocabulary a groupBy entry and every driver's bucket expression are typed by — declares five, @objectstack/core's BUCKET_GRANULARITIES labels the same five, and DriverCapabilitiesSchema.supports.queryDateGranularity is a z.record(DateGranularity, boolean), so a driver could not advertise sub-day bucketing even if it had one. Measured on the shipped faces before the narrowing: driver-memory's analytics face answered NOT_IMPLEMENTED/501, driver-mongodb's bucket builder answered NOT_IMPLEMENTED/501, and the engine's in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since NativeSQLStrategy declines on a granularity — answered 200 with one group per distinct timestamp, echoing the raw instant back as its own bucket label. Two honest refusals and one silently wrong answer, and no third behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics means widening DateGranularity, the queryDateGranularity record, the canonical bucket-key vocabulary and every driver's bucket expression together — new capability, decided as such
    • Done when: No analytics request body carries timeDimensions[].granularity of second, minute or hour, and no cube dimension offers one in granularities[] (the D2 conversion cube-sub-day-granularities-removed strips them from sources, dropping the key entirely when nothing coarser remains). ⚠️ The conversion cannot decide what a dimension that offered ONLY sub-day intervals should offer instead — review each site the run reports and state the granularities that dimension actually serves.
  • training-deadline-keys-retired — training duration and deadline keys: TrainingCourse.durationMinutes/validityDays, TrainingPlan.recertificationIntervalDays/gracePeriodDays/reminderDaysBefore`` → nothing to re-declare — delete the keys. No training-management engine exists on the platform: nothing schedules or times a course, computes a certification expiry, re-assigns training on an interval, escalates an expired certification or sends a reminder, so there is no live mechanism to declare a duration or deadline to
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-02 on the unread deadline keys (ruled A: retire per family). Five minute/day-shaped keys sat on the published authorable surface and in the generated reference docs — an author could write validityDays: 365 and reasonably expect a certificate to expire — and read by NOTHING: the schemas are exported from @objectstack/spec/system, mounted by no stack key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded) and over objectui at the pinned sha returned zero hits for every key. Three of the five carried defaults (365, 30 and 14 days) that were materialized into every parsed plan without ever being consulted. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent).
    • Done when: No TrainingCourse or TrainingPlan literal — standalone or as a courses[] entry — carries durationMinutes, validityDays, recertificationIntervalDays, gracePeriodDays or reminderDaysBefore. TypeScript authors get the refusal at compile time (each key is typed never); a value reaching the parse is refused with the prescription (invalid_type at the path of the key). Parsed plans no longer carry the three former defaults. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever read the keys, so removing them removes no behaviour.
  • training-family-retired — the training family, retired whole: the five defs system/TrainingCategory, system/TrainingCompletionStatus, system/TrainingCourse, system/TrainingPlan and system/TrainingRecord, and every name system/training.zod.ts exported from @objectstack/spec/system (the five *Schema consts, their z.input aliases and the two *Parsed aliases) → nothing to re-declare — no training-management engine exists on the platform, so there is no working configuration to migrate to. Nothing assigned a course, tracked a completion, sent a reminder or expired a certification; a training record the organisation keeps is ordinary object data, declared as an object with its own fields and enforced by the object engine. If training management becomes a product capability it re-declares fresh, through the enforce route of ADR-0049 — the engine first, the vocabulary second
    • Why not automatic: ADR-0049 enforce-or-remove; maintainer ruling 2026-09-05 on the families' remaining keys and defs (ruled A: retire the three compliance-shaped families whole via RETIRED_DEFS_BY_MAJOR, the integration/ErrorMappingConfig precedent; not roadmapped). Five defs and roughly twenty-five declared keys sat on the exported surface and in the generated reference docs, and were read by NOTHING: the schemas were exported from @objectstack/spec/system, mounted by no stack.zod.ts key, registered as no metadata type, absent from the 2026-06 liveness ledgers, and the reader census over every package outside packages/spec (tests and changelogs excluded), over examples/** and skills/**, and over objectui at the pinned sha returned zero hits for every exported name, with a lit control. TrainingCourse.mandatory, TrainingPlan.trackCompletion and TrainingPlan.sendReminders were boolean capability claims of exactly the shape ADR-0049 names: an author could write them, parse clean, and get no behaviour and no diagnostic. Tagging the family [EXPERIMENTAL — not enforced] was the fallback the ruling did not take (a human-only signal). The deadline-key tombstones of the 2026-09-02 per-family ruling (five sites, RETIRED_KEYS_BY_MAJOR[18], D3 training-deadline-keys-retired) leave with their defs' source; their registry entries stay as history. 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; none of these schemas is either, so a conversion would be a transform with no seam that ever runs (the kernel/MetadataPluginConfig:additionalTypes precedent), and with no carrier key there is no shape on which a tombstone could sit.
    • Done when: No code imports TrainingCategorySchema, TrainingCompletionStatusSchema, TrainingCourseSchema, TrainingPlanSchema or TrainingRecordSchema — or any of their type aliases — from @objectstack/spec or @objectstack/spec/system: every such import is TS2305 after upgrade, and no working replacement exists to point at because the vocabulary described nothing real. The five defs are absent from json-schema.manifest/system.json, the api-surface / declaration-map / export-origins shards and the generated reference docs. ⚠️ Runtime behaviour is deliberately UNCHANGED and must be verified as such: nothing ever parsed or read these shapes, so removing them removes no behaviour.
  • translation-component-submit-label-retired — translation.pages.<page>.components.<id>.submitLabel — the component-copy key of the retired element:form → The live form surface's submit copy: submitText on the object-form component, an I18nLabel localized at its own authoring site.
    • Why not automatic: The D2 conversion translation-component-submit-label-removed deletes submitLabel from every translation bundle and stored translation item, and the delete is lossless: the key's only declarer, element:form, retired whole, so no resolver has overlaid the string since and it was read by nothing. What the delete drops is translation WORK. A translator who localized a submit button for each locale did so because a user was meant to read it; if the page's form now lives on object-form, its submit copy is submitText, and that key is not filled by moving the old strings mechanically — the component ids differ, and a retired element:form may have no successor on the page at all. Only the author can say which form each string belonged to and whether it still exists.
    • Done when: No translation bundle or translation item carries a component submitLabel; the parse refuses it. For each form a user still submits, the object-form component carries a submitText whose localized values cover the locales the dropped strings covered, and switching the UI locale shows the submit button in that locale — or the author has decided the default copy is acceptable.
  • translation-per-app-settings-platform-only — stack.translations[].<locale>.settings and translation.settings — the settings group on the per-app bundle and on the registered translation item → Delete the group from the per-app bundle and from every translation item. There is no application-side replacement key: settings copy is not application-authorable at either door. settings is keyed by SettingsManifest.namespace, and only platform code declares a manifest (packages/services/service-settings/src/manifests/*.manifest.ts), so the only namespaces an application could ever address were the platform’s own. Platform settings copy is translated in the PLATFORM bundle — @objectstack/service-settings’s settingsBuiltinTranslations, typed PlatformTranslationData — which is where a correction to a platform string belongs. An application’s own copy goes in the 11 groups the per-app bundle and the translation item still declare, in the order they declare them: objects, picklists, apps, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. Note settingsCommon among them: it IS on both faces, so the Settings UI shell strings an application may translate (the source badges, under settingsCommon.sourceLabels) are NOT what is being removed here — only the per-namespace manifest copy under settings is.
    • Why not automatic: Not losslessly convertible, and NOT because the content was inert: what it did differs by door, and both effects are visible on screen. THE PER-APP BUNDLE — measured on this tree before the split: AppPlugin.loadTranslations hands each stack.translations bundle entry WHOLE to II18nService.loadTranslations, the adapter deep-merges it into the one per-locale tree, and every platform plugin contributes into that same tree — so settings from an app bundle and settings from @objectstack/service-settings land in one place. resolveSettingsTitle and the rest of the resolveSettings* family read it (pickSettingsEntry → pickData(bundle, locale)?.settings), and so does the console's useSettingsLabel, which scans every namespace carrying a settings branch. ORDER decides the rest, and it runs against the application: AppPlugin loads the app’s bundles in its own start() (kernel Phase 2), SettingsServicePlugin contributes the platform’s settings translations from a kernel:ready hook (Phase 3), and deepMerge gives the LATER source the leaf — AppPlugin’s own comment says as much (“the platform bundles have not arrived yet at this point in the lifecycle”). So the platform won every key both bundles defined, and what a per-app bundle actually had was a GAP FILLER on a namespace it does not own: the entry rendered only where the platform bundle carried no string for that key and locale (the platform ships en / zh-CN / ja-JP / es-ES), silently, with no way for the author to tell a filled gap from an ignored override. Dropping it takes those gaps back to the manifest’s own literal — the ?? fallback every resolveSettings* helper ends in, which is English. THE translation ITEM went further: a stored item is not loaded into the static tree at all but into the runtime-authored layer (authored-translation-sync → replaceAuthoredTranslations), and both i18n adapters read that layer OVER the shipped bundles (deepMerge(static, authored)), whatever order they loaded in. So an item’s settings OVERRODE the platform’s own copy for its locale — a published item could rewrite a platform Settings screen — which is exactly what the ownership ruling says an application must not do. Dropping it takes each overridden key back to the platform bundle’s string, and each key it had filled back to the manifest literal. A mechanical notice reading "(removed)" conveys neither. The two bundles are separate namespaces from this major on (ruling of 2026-09-13, letter ②: a platform bundle schema and a per-app bundle schema, settings absent from the per-app one), and the item door follows the file door (ruling of 2026-09-22, letter B: the file door and the item door are two authoring surfaces for ONE app metadata type, so they accept one shape; an admin override of platform copy, if ever wanted, is a platform-level feature, not app metadata). ADR-0049 enforce-or-remove supplied the question, not the answer — settings stays a LIVE platform key. No deprecation window: both doors refuse the key by name from this major, with the prescription on the rejection.
    • Done when: No application-authored face carries settings. defineTranslationBundle({ <locale>: { settings: … } }), a defineStack({ translations: [...] }) entry carrying it, defineTranslation({ locale, settings: … }) and a translation item saved through the metadata API carrying it are all refused as an unrecognized key, and each refusal names the group as platform-only rather than suggesting a rename (pinned in packages/spec/src/system/translation.test.ts; the metadata door answers 422 INVALID_METADATA, pinned in packages/metadata-protocol). The platform face still accepts it: PlatformTranslationDataSchema.parse({ settings: … }) succeeds, settingsBuiltinTranslations still type-checks, and GET /api/v1/i18n/translations/:locale still declares settings on its response (GetTranslationsResponseSchema), because the served document is the merged tree. A translation row ALREADY STORED with settings is not refused — a stored row has no author to teach — it is converted: the runtime sync replays this conversion before merging the row, logs the conversion notice once, and loads the rest of the item, so its settings stops overriding at the next sync; os migrate meta --stored --apply persists the canonical row. For a deployment that WAS authoring settings copy, re-read the Settings screens in each locale it covered: where a translation item overrode a platform string, the platform’s string renders again; where either door filled a GAP — a namespace, key or locale the platform bundle does not translate — the manifest’s own literal renders, which is English. If a platform string is wrong or missing for your locale, correct it in the platform bundle (@objectstack/service-settings’s settingsBuiltinTranslations) — ⛔ do not re-add app-side copy at either door, which is refused.
  • translation-widget-sub-caption-retired — translation.dashboards.<dashboard>.widgets.<id>.subCaption — the metric sub-caption overlaid onto a widget's options.description → The widget's one authored description, widget.description, rendered as the card-header subtitle and translated by dashboards.<dashboard>.widgets.<id>.description.
    • Why not automatic: The D2 conversion translation-widget-sub-caption-removed deletes subCaption from every translation bundle and stored translation item, and translateDashboard no longer overlays anything onto a widget's options. The sub-caption was the string under a metric's value; the dashboard schema never declared options.description and no authored widget wrote it, so a translated sub-caption existed only because this key put it there. What the delete drops is translation WORK: a translator who wrote a caption per locale meant a user to read it. The conversion cannot move those strings to description, because description already translates the card-header subtitle — a different string a widget may also carry — and only the author can say whether the caption's wording belongs in that subtitle or is no longer needed.
    • Done when: No translation bundle or translation item carries a widget subCaption; the parse refuses it. For each metric widget whose caption a user still needs to read, the copy lives in the widget's description and its localized values sit under the widget's description entry for every locale the dropped strings covered — or the author has decided the card-header subtitle alone is enough.
  • try-catch-and-retry-policy-undeclared-keys-refused — a retry policy carrying a key it does not declare (a typo such as maxRetry, a key borrowed from another retry vocabulary such as baseDelayMs or maxAttempts, or a key nothing reads), wherever the policy is written: a job retryPolicy, and the retry block of a try_catch flow node; and a try_catch flow node whose config carries a key beside try, catch, errorVariable and retry that its executor contract does not declare. Never a key on the try or catch region object or on its nodes and edges (the region check at registration owns those). Reachable wherever a job or a flow is authored or stored: defineStack sources, defineFlow(), an exported stack passed to objectstack validate or objectstack compile, a flow saved from the Studio flow designer, and a flow row already sitting in sys_metadata → the key the policy declares, or no key: maxRetries (retries after the first attempt), backoffMs (the base delay), backoffMultiplier, maxRetryDelayMs and jitter. Rename a typo to the declared key the refusal's did-you-mean names; write a delay borrowed from another vocabulary as backoffMs or maxRetryDelayMs; write a count of total attempts as maxRetries one lower (maxAttempts 3 is maxRetries 2); delete a key nothing reads. A retryDelayMs is still answered by its own tombstone: rename it to backoffMs
    • Why not automatic: RetryPolicySchema was a plain z.object, which strips a key it does not declare. Its defaults are opt-in (maxRetries 0, backoffMultiplier 1), so a stripped key falls back to "no retry" or to a flat delay: a job.retryPolicy with maxRetry: 3 parsed, deployed and never retried, and nothing said so. On a try_catch node the strip also kept the flow parse from judging the node's keys: its descriptor closes retry to five keys, so registerFlow's undeclared-key walk (validateNodeConfigKeys) refused a key the contract would have accepted, and flow-builtin-node-config-undeclared-keys-refused left try_catch to that walk. A retry.maxRetry typo or a bogusKey beside try therefore passed objectstack validate and objectstack compile and was refused only when the flow registered. The policy is now a strictObject: an undeclared key is refused at parse, naming the key, with a did-you-mean for a near miss. Measured before closing it: every writer of either parser in this repository and in the pinned objectui writes only declared keys, so it is closed on the shared schema. The one judge FlowSchema.parse, AutomationEngine.registerFlow (which parses first), objectstack validate and the metadata save door share (flowNodeConfigRefusals) now judges try_catch keys like every other builtin's, as node-config-refused-by-contract anchored at the key (nodes.N.config.retry.maxRetry), and the descriptor walk stands aside for it, so it keeps plugin node types only. The descriptor's declared key sets equal the contract's at every position the walk descends to, so registration refuses what it refused before. ⚠️ The one key the walk refused that the contract declares is the retryDelayMs tombstone. The retry-policy-converged conversion renames it before every door that converts first, but keeps it beside a backoffMs holding a different value, and leaves it when it is null. The key arm refuses what survives at nodes.N.config.retry.retryDelayMs, in the tombstone's own words, so registration widens nowhere. A script node's retired keys keep the scope they had. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was meant to be. ⚠️ Where such a node already sits, the whole flow is refused, as registration already refused it: from the metadata registry or sys_metadata at boot it is skipped with a warn naming it, while the flows beside it register; a defineStack source throws StackSchemaInvalidError; a save from Studio answers 422 naming the key. A job whose retryPolicy carries such a key is new to refusal (no door judged one before): its defineStack source throws StackSchemaInvalidError at jobs.N.retryPolicy, and an artifact carrying it is refused whole at load. ADR-0087, ADR-0031.
    • Done when: Run objectstack validate over every stack authored in config files, and boot every deployed stack. Each refusal names the key: a job's at jobs.N.retryPolicy with the unrecognized key and its did-you-mean, a flow's at nodes.N.config.retry.<key> or nodes.N.config.<key> for a try_catch node, and validateStackExpressions phrases it as node 'n' (try_catch) config.retry.maxRetry. For each hit rename or delete the key per the replacement. Two proofs. (1) For a stack authored in config files, objectstack validate is clean. (2) Boot the stack and confirm each flow REGISTERS: no failed to register flow warn for it. A retry policy and a try_catch node whose keys the contract declares parse and register byte-identically to before.
  • turso-config-forced-local-with-sync-url-refused — data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a replica → the configuration the author meant. An embedded replica drops mode: keep the file: url and syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url (or :memory:) with no syncUrl is a local database, with or without mode local
    • Why not automatic: A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed refusing this shape against honouring mode local by skipping the sync, and refused it: honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a loud contradiction is the author's to resolve. A forced mode local beside a syncUrl parsed clean at authoring, and the driver built it with a local transport label and then ran it as a replica: it synced on connect, started the sync interval and answered true to the sync-enabled check, exactly as the same config with no mode did (measured on the driver source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is unset and is not refused. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode or syncUrl. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report a forced local mode beside a syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded replica (drop mode) or a local file (drop syncUrl and sync). Done when every turso datasource parses, the driver builds from it, and no datasource that declares mode local carries a syncUrl.
  • turso-config-forced-replica-without-sync-url-refused — data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of @objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on mode at authoring and at construction. The published TursoConfigSchema mirror of @objectstack/driver-turso carries the same text for parity but declares no mode key and strips an authored one, so it cannot see a forced mode and still accepts the config as a local file → the configuration the author meant. An embedded replica names the remote it replicates from: keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a file: url with no syncUrl and no mode is a local database
    • Why not automatic: An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against documenting a replica with no remote as a local mode, and refused it: with no remote there is no replica mode to document, only a declaration nothing honours. A forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as a replica that never synced: no sync client was created, no sync interval started, the sync call did nothing and the sync-enabled check answered false, while every read and write went to the local file (measured on the built driver). A declared mode the runtime never runs is the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the constructor now refuse it together, with one message, which names both ways out. The sibling refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource rows are not re-parsed when they load, so a stored row in this shape now fails when its driver is built: the connection service records it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets mode. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report a forced replica with no syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded replica (set syncUrl) or a local file (drop mode). Done when every turso datasource parses, the driver builds from it, and every datasource that declares mode replica carries a syncUrl.
  • turso-config-timeout-unit-in-key — datasource.config.timeout on the turso driver — the per-request time limit → timeoutMs — the same limit, in milliseconds, beside the sibling sync.intervalSeconds that already spelled its unit.
    • Why not automatic: The D2 conversion turso-config-timeout-to-timeout-ms renames config.timeout to config.timeoutMs on every datasource whose driver resolves to turso (a stored libsql spelling included) and leaves every other driver's timeout alone; the rename is lossless because the key always meant milliseconds. The judgment is whether each value was written in that unit. Two keys above it, sync.intervalSeconds spelled SECONDS, so one config block carried both conventions, and the unit of timeout lived only in a description and a title no parse reads: timeout: 30 meant as thirty seconds became a thirty-millisecond limit, short enough to fail a remote request, and the rename keeps 30. Only the author can say which unit they meant. Code that builds a turso driver config in TypeScript is outside the chain's reach.
    • Done when: No turso datasource carries config.timeout; the spec contract refuses it with the rename. Every timeoutMs value is the limit the author intends in milliseconds — a datasource meant to allow thirty seconds reads timeoutMs: 30000 — and queries against the remote database complete as they did before the upgrade. No code reads or writes timeout on a turso driver config.
  • turso-config-transport-mismatch-refused — data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url that is none of a file: url, :memory: or a remote url (a bare path, another scheme, :MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync with no syncUrl, as the spec contract already did → the configuration the author meant, spelled the way the driver runs it. A remote database is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or set it to remote). An embedded replica is a local file written as a file: url beside syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or :memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or timeoutMs) and prints the spellings above
    • Why not automatic: Each key parsed on its own, so the contract accepted configurations the turso driver refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the fixes that stopped a remote url beside a syncUrl from writing to process memory and an unrecognised url scheme from falling through to an in-memory local engine) — a datasource published clean and then failed at boot or at test connection. One more it built and then ignored until the constructor was taught to refuse it as well: syncUrl under a forced remote mode, where the remote client was created without it, no sync ever ran and the sync call failed as not supported while the driver reported sync as enabled (measured on the built driver). Authoring now refuses exactly the constructor's refused set — the same predicates, a scheme matched in any letter case, the url read trimmed as both datasource loaders hand it over — plus that key, refused at authoring first as the declared-but-not-enforced shape ADR-0049 does not ship, and by the constructor too since that later fix. Nothing the constructor accepts is refused (when authoring first refused that key it was the one exception; since the constructor refuses it too there is none): a forced remote mode keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed when they load, so a stored row still reaches the constructor as written; the constructor refuses the first four shapes there already and, since that later fix, also refuses syncUrl under a forced remote mode and sync with no syncUrl when the datasource boots. What changes here is that creating, testing or editing its config through the datasource admin service, defineStack or os validate is refused at the key. Measured on this tree at the change: no example, template, published skill or hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Validate every stack and re-save every turso datasource: os validate or defineStack, and a save or test connection through the datasource admin service, report each refused combination at config.url, config.syncUrl or config.timeoutMs with the ways out. Decide per datasource whether it is a remote database, an embedded replica on a local file, or a local file, and rewrite it to that spelling. Done when every turso datasource parses, the driver builds from it, and a replica datasource reports a file: url beside its syncUrl.
  • ui-action-group-menu-member-params-array-only — page action:group and action:menu components — a member of properties.actions whose type is not api, and whose params is not an array → Write params as the list of inputs to collect from the user, an ActionParam[] array. To run an action with static parameter values, author it as its own action:button node, whose params object carries them; for a type: 'api' member's request body write bodyExtra. A member that needs neither drops the key.
    • Why not automatic: An action:group or action:menu runs each member itself and forwards an array params as the input list. It forwards any other params value only for a type: 'api' member, as its request payload; for every other type, an absent one included, it drops the value, with a development-build warning only. The member declared params as any value, so an object params on such a member passed the component-props gate and then had no effect: no error and no static values. params carries one shape, the input list, and no second value-bag key is declared; a member's properties.params is already refused, so static parameter values are not part of the inline action vocabulary at all, and the action that needs them is its own action:button node. The member now refuses a non-array params on a non-api type at the gate, at actions.N.params, with that prescription. The api member's object params is unchanged. It is read where every page component's props are: the component-props gate reports the refusal as an advisory component-props-invalid finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: the static values belong on a different node, and the census found no writer. Deployed metadata NOT MEASURED.
    • Done when: Every action:group and action:menu node validates: objectstack validate reports no component-props-invalid finding at properties.actions.N.params. Each member whose action needs static parameter values is now its own action:button node, and pressing it hands the handler those values. Census at the time of the change: no action:group / action:menu member authors a non-array params on a non-api type in this repository, in objectui (at the pinned commit and on its main branch) or in the hotcrm application, outside objectui's own tests asserting that the container drops it; the cloud repository was not reachable.
  • ui-action-group-menu-members-typed — page action:groupandaction:menucomponents — each member ofproperties.actions (whose keys used to pass unjudged) → an inline action with action:button's keys, its executor spelled type: { name?, label?, icon?, type?, variant?, visible?, disabled?, tags?, params?, description?, target?, openIn?, method?, bodyExtra?, bodyShape?, operation?, patch?, confirmText?, successMessage?, errorMessage?, refreshAfter?, locations?, toast?, resultDialog?, onSuccess?, objectName? }, plus size? on an action:group member. Write actionType as type, endpoint (and url / path / href) as target, enabled as disabled with the condition inverted, and outcomeMessages as one successMessage; drop a member className, properties, autoTrigger, undoable, recordIdField and an action:menu member's size.
    • Why not automatic: An action:group or action:menu draws and runs each member itself: it draws label (or name), icon, variant, tags and, on a group's inline buttons, size; gates the member on visible and disabled; places it by locations; and forwards its type and the rest of action:button's keys to the action runner. The page-component rows declared each member an open record, so a misspelled key, a node-style actionType or an endpoint no api handler reads passed the component-props gate, and the container drew and ran the member without it. The rows now take a closed member: action:button's keys by type, with the rows' prescriptions; the keys the rows leave undecided — outcomeMessages, a member className, a member properties.params — are refused, and outcomeMessages stays undeclared on all four action blocks as one decision. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working member to respell. Deployed metadata NOT MEASURED.
    • Done when: Every action:group and action:menu node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.actions. Each member is drawn with its label, icon and variant, and runs the executor its type names.
  • ui-action-undoable-unfulfillable-refused — action` documents declaring `undoable: true` on a shape no runtime fulfils — `type: 'script'` (the default route) and `type: 'url'`, plus the dormant `type: 'flow'` / `'modal'` / `'form'`, in each case WITHOUT `operation: 'update' → either of the two fulfilled shapes — operation: 'update' with a patch, where the framework runtime snapshots the prior value of every field in the merged write bag, or type: 'api', where the pinned console builds the undo envelope — or no undoable at all. ⛔ NOT mechanically convertible: which of the two the author meant is an intent no artifact records (a script action with an inline handler and an api action calling an endpoint are different dispatches, not two spellings of one), and dropping the flag silently would remove an Undo the author asked for. The refusal names both shapes and the drop, and the author chooses
    • Why not automatic: The key was a plain optional boolean read by no refinement, so every combination parsed clean while only two of them ever produced an Undo — the declared-but-inert shape ADR-0078 refuses at author time. ⚠️ The obvious repair, requiring operation: 'update', was MEASURED WRONG and is deliberately not what this entry records: the pinned console's two readers gate the undo envelope on action.undoable alone with zero reads of action.operation, and those same two files are the entire recorded evidence for this package's own liveness verdict action/undoable: live. A blanket requirement would therefore have refused the published ReassignLeadAction skill example (type: 'api' + undoable: true, no operation) at import time, since defineAction IS ActionSchema.parse, and every console api action with undo along with it. So the accepted set is closed to the two shapes some runtime fulfils rather than to the one the framework runtime fulfils. Stating "type: 'api' is fulfilled by the console" in the contract is the point, not a leak: the spec is the contract for every runtime including the console, and a closed table of fulfillable combinations is what the declared-is-delivered rule asks for.
    • Done when: Every action document declaring undoable: true carries operation: 'update' or type: 'api'. Both fulfilled shapes parse byte-identically to before — the published ReassignLeadAction example included — and an action with undoable absent or false is untouched on every type. An action declaring undoable: true on any other shape is refused with a per-key issue at undoable whose message names both fulfilling shapes and the runtime that fulfils each; the author adds the shape they meant or drops the flag.
  • ui-ai-chat-window-retired — page.component.ai:chat_window — the component node, with every key its props bag declared (mode, agentId, context, aria), in regions, named slots and nested containers alike → Delete the component node and put nothing in its place: AI chat is not a page element, and the floating chat overlay the console mounts on every page is the supported entry point. To choose which platform agent the overlay answers with — what agentId reached for — set the app's defaultAgent (a platform agent: ask, or build on an authoring surface). mode, context and aria have no counterpart on the page: none of them was ever read, and the overlay is not configured per page
    • Why not automatic: No renderer for ai:chat_window ever shipped in objectui, framework or cloud, and none is wanted: the console leaves it unregistered on purpose so that a page naming it fails loudly, and Studio's page palette excludes it. So the element and its four keys were a capability claim nothing kept — a page that placed one validated clean and drew "Unknown component type" in front of an end user. Zero producers were measured in objectstack, cloud and hotcrm (one comment naming it as dropped). The name is now refused at PageComponentSchema.type, its ComponentPropsMap row refuses every props bag with the same prescription, and the enum no longer lists it; ai:suggestion is unchanged. No conversion is registered, because the only edit is deleting the node, and which region closes up, holds something else, or keeps its slot is the author's judgment about a page they composed — this entry is that delegation
    • Done when: No ai:chat_window component remains in any page — regions, named slots and nested containers alike. os validate is clean: a remaining node is reported at its own type path with params.retiredComponentType naming ai:chat_window, so each one is named individually rather than as one page-level failure. An app whose removed node named an agentId now names that platform agent in its defaultAgent instead, or leaves it unset for the default ask. Replaying the 17 → 18 chain over the edited source then reports the migrated stack schema-valid — schemaValid: true in --json
  • ui-bulk-action-param-unknown-keys-refused — a list view's bulkActionDefs[].params[] entry (BulkActionParamSchema) — undeclared keys, which this shape accepted and forwarded while it was .passthrough()`` → the declared shape, now closed to match its single-record twin ActionParamSchema: { name, type } plus label, help, required, default, options, object, labelField, multiple, placeholder and — new in this release — dependsOn. Every rejection names the surface, echoes the offending key and carries a rename or a prescription: the action-param spellings rename onto this surface's words (helpText → help, defaultValue → default, reference → object, displayField → labelField); the keys that belong one layer out are pointed there (visible and a capability gate belong on the DEF, visibleWhen belongs on an options[] entry, field / objectOverride / carryOver / defaultFromRow / requiresFeature are field-backed ACTION-param contracts the bulk surface does not implement); and the widget-config family (min, max, step, precision, scale, rows, accept, maxSize, and the picker knobs lookupFilters / lookupColumns / lookupPageSize / descriptionField / picker / subtitle / avatarField / idField / allowCreate) is answered with one prescription naming FieldSchema as the shape those keys are real on. dependsOn needs NO edit — it is declared, in the same shape the field-level key takes (['parent'], or [{ field, param }] when the remote filter key differs).
    • Why not automatic: The accept was a NULL READING, and that is what makes this a contract fix rather than a preference. Measured against installed spec 17.4.0, three parses per schema in one process: BulkActionParamSchema accepted zzz_nonsense_key_that_no_producer_emits_8755 in the SAME RUN that it accepted dependsOn, while ActionParamSchema one surface over refused both with unrecognized_keys. A shape that examines nothing cannot license anything — so "the bulk schema accepts it" was never evidence a key was authorable, and every misspelling and every invented key shipped silently. Not losslessly convertible for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target and auto-deleting it would be the silent data loss ADR-0078 bans, so each occurrence needs an author's decision. ⚠️ Two halves of this are worth knowing before you upgrade. (1) dependsOn was already LIVE on this surface and is kept: bulkParamToField does not destructure it out, so it rides the adapter's spread onto the field bag, where the option widgets read it through useCascadingOptions and the reference-bearing pickers lower it into a candidate filter; an ablation removing it from that spread reddened 7 of 12 cases in the consuming repo, so retiring it was measured off the table. (2) the widget-config family rode the same spread and really was honoured by whichever widget read it — those keys are refused now rather than forwarded, which is the accepted cost of closing the shape (maintainer ruling, letter A, 2026-09-17: 「Breaking for authored metadata」, one-shot, no grace window and no dual spelling). ⛔ Do not read their rejection as "the renderer ignores them", and ⛔ do not answer it by declaring the key on the object's FIELD: the bulk surface has no field-backed param route, so that value does not reach this dialog either. A census of authored bulk params taken at registration time over the two repositories reachable from that session found ZERO carrying an undeclared key (objectstack@176b03582e: 7 param literals; objectui@3e4f6324f7: 3), so no in-corpus configuration is known to break. ⚠️ That census did NOT cover hotcrm, which was unreachable from the session that took it — that leg is UNMEASURED, not clean, and an upgrader with their own metadata corpus should run the check below rather than inherit this result.
    • Done when: Every bulkActionDefs[].params[] entry in your stack parses with declared keys only — objectstack validate (and os lint / os build) reports no unrecognized_keys under a params path. Each rejection carries its own fix; apply the rename it names, move the key to the layer the prescription points at, or delete metadata that was never read. ⚠️ Parsing clean is the weaker half here, because the widget-config keys were being HONOURED rather than dropped: for every param that carried one, re-open the bulk dialog and confirm the control still behaves as authored (a number param's bounds and step, a file param's accepted types and size cap, a picker's base filters and columns) — where it does not, the configuration is genuinely gone and the remedy is a spec issue asking for the key, not a local workaround. dependsOn needs no action: re-open one bulk dialog that declares it and confirm the dependent control is still gated until its parent param is filled, and that picking the parent still narrows the child.
  • ui-cloud-connection-widgets-unknown-keys-refused — page cloud-connection:panel/marketplace:installed-listcomponents —properties (any key at all: both widgets declare no props) → an empty properties bag ({}), or omit properties entirely. Neither widget reads any prop: the console registrations discard the schema node (() => <Widget />) and the components take no arguments, so there is no declared key to move to — a key authored on either widget configures nothing and is removed, not renamed. Node-level keys (visibleWhen, id, style, …) stay on the component node, where the page runtime reads them.
    • Why not automatic: These were two more instances of the class already closed for record:reference_rail and then for record:alert / record:quick_actions / record:history, each by declaring a strict ComponentPropsMap row measured from the renderer's read points: console-registered widgets on @objectstack/cloud-connection's published Setup pages, reachable through the component type union's open string arm, with registered renderers but no ComponentPropsMap row — so the props gate's dispatch (it parses properties against the type's row at publish and lint time, and skips a type with no row because the type union is open) skipped them as unregistered and any authored key rode through every validator in silence. The new rows are strict and EMPTY, measured from the renderers' actual read points at the objectui pin (not from the registrations' declared-input lists): both registrations ignore the component node entirely, so the widgets accept no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op.
    • Done when: Every cloud-connection:panel / marketplace:installed-list node authors an empty (or absent) properties bag and validates clean — the two plugin-shipped pages (cloud_connection_settings, marketplace_installed) already do; objectstack validate reports no component-props-unknown-key finding for these types. Any remaining authored key on either widget is deleted (it never configured anything), and behaviour that seems to need one is a renderer capability request against objectui, not a metadata key.
  • ui-form-field-length-malformed-refused — form-view field row maxLength/minLength declarations (FormFieldSchema, the rows inside FormView.sections[].fields[]) — 0, negative or non-integer values → a positive-integer bound (>= 1), or no declaration at all ("no minimum" is expressed by OMITTING minLength, never by minLength: 0). The row-level key is a per-form override that can only NARROW what the referenced object field already declares (the object field surface tightened first, to a positive integer, by maintainer rulings) — so a malformed row value is deleted, and a bound that was actually wanted is re-declared as a positive integer, or dropped in favour of the object field's own authoritative declaration
    • Why not automatic: The form-field row still carried the object field's old shape — bare z.number() — after that surface converged (maxLength by the maintainer's 2026-08-24 ruling, minLength by the 2026-08-25 one, which refused zero too: both z.number().int().min(1)). The row keys are LIVE, measured in objectui: the spec bridge (packages/react/src/spec-bridge/bridges/form-view.ts mapField, which maps every spec key or explains why it does not, so none is dropped in silence) and plugin-form (sectionFields.ts normalizeSectionField) both copy them onto the runtime field, the console FormPage merges override.maxLength ?? def.maxLength onto the rendered input (fixed so that a form's own bound wins over the object's, as its docstring promised), and the fields package builds react-hook-form validation rules from minLength/maxLength — so maxLength: 0 on a form row reached the DOM as an input that accepts nothing, and the public-form resolve route (GET /forms/:slug) serves the rows verbatim to anonymous renderers. The schema now refuses the malformed values at parse (z.number().int().min(1), ADR-0078 declared=enforced). Unlike the object-field twins there is NO type-conditional gate: a form row references its object field by name and usually omits type, so the referenced field's type is invisible at parse time — value shape is checkable on this surface, key placement is the object field's own schema's job.
    • Done when: Every form-view field row declaring maxLength or minLength carries a positive integer. Well-formed rows (a positive-integer bound) parse byte-identically to before; rows declaring neither key are untouched, and absence stays absence — no default materializes. A stored form view carrying a malformed row value is refused on its next authoring-path save with a prescriptive per-key issue; the author deletes the key (the object field's own declaration keeps governing the write seam) or re-declares the intended positive integer.
  • ui-form-field-precision-scale-integer-refused — form-view field row precision/scale declarations (FormFieldSchema, the rows inside FormView.sections[].fields[]) — non-integer or negative values (scale: 2.5, precision: -1) → a non-negative integer digit count, or no declaration at all. The row-level key is a per-form override of the referenced object field's own declaration (that surface tightened first, to a non-negative integer) — a malformed row value is deleted, and a count that was actually wanted is re-declared as a non-negative integer (scale: 2.5 was probably 2 or 3)
    • Why not automatic: The form-field row still carried the object field's old shape — bare z.number() — after that surface converged on z.number().int().min(0) for both digit counts. The row keys are LIVE, measured in objectui: the spec bridge (form-view.ts mapField, which maps every spec key or explains why it does not) and plugin-form (sectionFields.ts) copy them onto the runtime field, ObjectForm derives the number input's step from precision, and the NumberField widget reads scale — so a malformed count flowed into rendering arithmetic (Math.pow(10, -precision)) with no defined meaning. The schema now refuses non-integer and negative values for both keys at parse time (ADR-0078 declared=enforced). Same no-type-gate rationale as the length pair entry (ui-form-field-length-malformed-refused): the row usually omits type, so only value shape is checkable on this surface. ⚠️ The timeline view's scale enum (TimelineConfigSchema.scale) is a different surface and is unchanged; the gantt view has no scale key at all — its own granularity key is viewMode. CurrencyConfigSchema.precision was also a different surface — retired in this same protocol major by currency-config-precision-removed, not enforced here.
    • Done when: Every form-view field row declaring precision or scale carries a non-negative integer. Well-formed rows (0, 2, any non-negative integer) parse byte-identically to before; rows declaring neither key are untouched, and absence stays absence. A stored form view carrying a malformed row value is refused on its next authoring-path save with a prescriptive per-key issue; the author deletes the key or re-declares the integer they meant.
  • ui-form-layout-inline-grid-retired — form layout— theobject-form page component (ObjectFormPropsSchema.layout) and the form view (FormViewSchema.layout: view.form, view.formViews.*, a form view item's config, a flattened form overlay): the inlineandgrid arms (REMOVED) → layout: 'vertical' | 'horizontal', or no layout at all ('vertical' is the renderer default). A multi-column form is columns (e.g. columns: 2), which the renderer honours under either layout — it was never a layout value. 'grid' → 'vertical' and 'inline' → 'vertical', with any columns beside them kept as authored.
    • Why not automatic: Both surfaces declared vertical | horizontal | inline | grid, and no renderer ever gave inline or grid a behaviour of its own. Measured at the objectui pin f8a9d0fb0: the simple object-form arm folds both to vertical under a comment saying exactly that, the drawer and modal arms pass only vertical / horizontal through, and the tabbed, split and wizard sub-forms hard-code vertical — so both values parsed green at the spec door and rendered as the default. The spec admitted them from two declarations (the designer palette and the registry inputs), never from a read. The maintainer's ADR-0049 family criterion asks whether mainstream platforms have the capability — if they do, build the consumer once, correctly; if they do not, retire the key — and not whether anything in this repository reads it. Multi-column, the capability grid names, is one they have, and this spec already carries it under another key, columns; inline is a toolbar / filter-row pattern, not a record-form layout. So the two arms are redundant vocabulary rather than a missing consumer, and are retired with no alias window. The mechanical rewrite is the ADR-0087 D2 conversion form-layout-inline-grid-to-vertical (retired from the load path — both enums refuse the two values at parse with a per-value prescription; stored rows and assembled artifacts replay clean). It is behaviour-preserving: the rewritten form renders exactly as before. What it cannot decide is whether an author who wrote grid without columns wanted a multi-column form they never got — that form always rendered single-column, and only the author knows whether that was the intent.
    • Done when: No authored object-form component or form view carries layout: 'inline' | 'grid'; objectstack validate passes. For every form that was rewritten from grid, decide whether it should be multi-column: if so, author columns with the count you meant (the rewrite never invents one); if not, the rewritten vertical — or deleting layout — is already what the form rendered.
  • ui-form-view-predicate-features-root-refused — form-view predicates naming the features.*scope root — section-levelvisibleWhen, field-level visibleWhenat any nesting depth, and per-optionvisibleWhen authored inline in the form view (FormViewSchema, including the flattened runtime form overlay and the deprecated visibleOn alias spellings) → gate by record state (record.* in runtime forms, data.* in metadata forms), or move the feature-gated surface onto an app page component or action — the predicate surfaces where features.* stays bound and stays legal. No rewrite is mechanical: a feature-flag gate and a record-state gate answer different questions, so the author chooses which surface the gate belongs on.
    • Why not automatic: Ruled by the maintainer on 2026-08-27 (option B — vocabulary narrowing: a form view may not name features.* in a predicate, and the authoring door refuses it loudly): one authored form view is served on two kinds of route, and a features.* predicate got two verdicts from the same text. Inside an app (/apps/:appName/*) the root resolves against the real auth-config flags; on the standalone form routes (/forms/:name, public /f/:slug) no app context exists, the root is UNBOUND, the predicate faults — and visibleWhen's fault fallback is visible, so the field or section a feature flag was meant to hide is shown to everyone (fail-open, on an access-shaped key). Measured before ruling and re-verified at dispatch (2026-08-28): ZERO authored features.* form-view predicates exist across objectui apps/examples/content, against an 18-hit positive control on authored visibleWhen predicates — so the vocabulary is narrowed at the authoring door instead of building an auth-config fetch plus pre-load semantics on a route with zero consumers. App-context predicate surfaces (page components, actions, bulk-action eligibility) keep features.* unchanged.
    • Done when: A form view carrying a predicate that names features in root position (dotted member access, index access, or the bare root — outside string literals) is refused at parse with a prescriptive issue naming the root, the surface, the fail-open reason and the ruling. Predicates on permitted roots parse unchanged, including member access on a record field that happens to be named features (record.features.x). Stored form views are unaffected until their next authoring-path save (zero such documents were measured to exist); on refusal the author re-gates by record state or moves the gate to an app surface.
  • ui-html-page-div-refused — kind:'html' page source (and its deprecated kind:'jsx' alias) in a project with no sdui.manifest.json of its own — the div tag, and any other tag or prop the SDUI component manifest shipped in @objectstack/console does not declare → box for a plain wrapper — the one drop-in swap: the same element, your className verbatim, the same children, and no layout of its own. Reach for card, flex, container, stack or grid only where you want their layout. For any other tag or prop the command names, a component and prop the manifest declares; the file is dist/sdui.manifest.json inside @objectstack/console.
    • Why not automatic: An html page's source is a JSX string, not a keyed document: objectstack migrate meta rewrites stored metadata by key and cannot rewrite a tag inside authored source, so the move is by hand, and which wrapper keeps a page's layout is the author's call. objectstack validate, objectstack compile (which dev and start run before they boot only when the artifact is missing or --compile is passed, and dev's watch mode when a watched file changes) and objectstack lint check that source against an SDUI component manifest: the sdui.manifest.json in the directory the command runs in, then the copy @objectstack/console ships. The second lookup asked for a file the console package does not export, so it always failed, and a project without its own manifest had its html pages checked at parse level only — syntax and structure, never which components and props they use. It now reaches the shipped copy. That manifest declares the html tier's intrinsic tags but not div: the maintainer ruled (2026-09-27) that an html page may author the intrinsic tags its renderer registers and that the published manifest declares that set, while div stays deprecated in favour of box, and the console's own html-page compile refuses div the same way. A div in such a page, which used to pass unchecked, now fails the command with jsx-forbidden-tag and jsx-unknown-component. A project that keeps its own sdui.manifest.json is checked against that file, as before. The runtime save door now holds pages to the same manifest: a server that objectstack serve runs (dev and start run it too) resolves the deployment's manifest the same way, from the sdui.manifest.json beside the served config and then the copy @objectstack/console ships, and the metadata save door compiles an html page's source against it on every publish. A div page saved from Studio or through the metadata API is refused with a 422 under the same rule ids, and a draft is stored as written and refused at its publish. A server that resolves no manifest says so once at boot and stores html pages unjudged, as before. Pages already stored are not rewritten; each is judged the next time it is saved.
    • Done when: objectstack validate reports no jsx-forbidden-tag, jsx-unknown-component or jsx-unknown-prop finding on any kind:'html' page and prints no sdui/jsx-parse-level-only notice — that notice means the component check did not run, so a clean result beside it proves nothing; objectstack compile and objectstack lint agree. No html page source authors div, and in the console each rewritten page renders with no compile error. The platform's own reference is the showcase app's three html pages, which author their wrappers as box and pass with zero findings.
  • ui-list-view-groupbyfield-padded-refused — list-view group-by field names — kanban.groupByField (KanbanConfigSchema, REQUIRED), gantt.groupByFieldandtimeline.groupByField (GanttConfigSchema/TimelineConfigSchema, both optional) — values carrying leading or trailing whitespace → the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: ' stage' becomes 'stage'. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message, next to the name to write instead.
    • Why not automatic: The same padded-name defect the grouping-level narrowing (ui-list-view-grouping-field-padded-refused) refused, on the axis that one scoped out by name, and given the same refusal. All three keys were a bare z.string(), so a padded group-by name was valid authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, measured in objectui at dda8f3815: the kanban board resolves its lane as laneField = groupByField || groupField || detectStatusField(objectDef) and buckets cards by card[laneField]; ObjectGantt's groupByAccessor splits the name on . and walks the backing record (resolvePath(task.data, field)); the timeline groups its rows the same way. The server answers under the unpadded name, so every per-row lookup reads undefined and the board collapses into one Uncategorized lane — the gantt and the timeline into one ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true statement about the data: one giant bucket is indistinguishable from a dataset where the field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. packages/lint's validate-list-view-field-refs already grades this position error for the same consequence, but it only runs where an app is validated against its object definitions; the producer accepted the value regardless. ⛔ NOT a .trim(): a trimming schema makes ' stage' and 'stage' silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working) — and on the REQUIRED kanban key the author cannot withdraw the value by omitting the key, so a normalising producer would be their only feedback channel and it would say nothing. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar /^[a-z_][a-z0-9_]*$/ this package spells inline for object/field/tool NAMES: a groupByField is authored as a field REFERENCE and a dotted relationship path (owner.name) is an in-tree spelling of one. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).
    • Done when: Measured against the shipped schemas, not restated from the card. Every stored view whose kanban.groupByField, gantt.groupByField or timeline.groupByField carries leading or trailing whitespace is refused on its next authoring-path save, with a custom issue at that key's own path (groupByField, or kanban.groupByField when the view is parsed whole) naming the offending spelling verbatim and the trimmed name to write instead; a value that is nothing but whitespace is refused with the remedy "Name the field to group by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, on purpose: whitespace INSIDE the name ('Group by field' parses), and the EMPTY string (unchanged on all three keys, this narrowing covers the silent case only). Nothing is normalised on the way through — an accepted name arrives byte-identical, 'owner.name' included — so a consumer proves the migration by re-saving each view and seeing either a refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every groupByField spelling in the repo at the time of the change parses unchanged: 14 distinct literals harvested across every .ts / .tsx / .mdx / .json / .mjs outside node_modules, zero of them padded, so no fixture had to be rewritten to keep the tree green.
  • ui-list-view-grouping-field-padded-refused — list-view grouping level names — grouping.fields[].field (GroupingFieldSchema, the rows inside ListView.grouping.fields[]) — values carrying leading or trailing whitespace → the field name written with no leading and no trailing whitespace — the same spelling the object declares and the server answers under. A padded value is RE-AUTHORED, never trimmed on the author's behalf: ' business_unit ' becomes 'business_unit'. The refusal names the offending spelling verbatim, so the whitespace an author cannot see in an editor is visible in the message.
    • Why not automatic: Ruled by the maintainer on 2026-09-10 (「其他同意」): refuse at the producer. field was a bare z.string(), so a padded grouping level was valid authored metadata all the way to the renderers. Measured on objectui (M1-M11 with live controls): the projection harvester collectGroupingFieldRefs TRIMS the name when it builds $select, while THREE renderers bucket rows by the RAW name — plugin-grid usableGroupingFields, plugin-list ObjectGallery.groupedItems, plugin-kanban effectiveSwimlaneField. The server therefore answers under business_unit while every per-row lookup asks for ' business_unit ', reads undefined, and the view collapses into ONE (empty) group (grid, gallery) or ONE Uncategorized lane (kanban) holding every record — a silent wrong answer that reads as a true statement about the data, which is why nothing weaker than a parse refusal is honest here. ⛔ NOT a .trim(): a trimming schema makes ' a ' and 'a' silently equivalent, the consumer-tolerance direction the contract-first rule refuses (fix the metadata, not the renderer: off-spec metadata is refused where it is authored, never coerced into working). objectui's harvester trim stays as defence-in-depth; nothing is removed there. The narrowing is non-padded ONLY and deliberately not the snake_case machine-name grammar /^[a-z_][a-z0-9_]*$/ this package spells inline for object/field/tool NAMES: a grouping level is authored as a field REFERENCE and a dotted relationship path (owner.name) is an in-tree spelling of one. The blank name is unchanged here — it is already refused loudly one layer down by compileListViewGroupQuery's grouping_field_blank, and this narrowing exists for the SILENT case. Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).
    • Done when: Every stored list view whose grouping.fields[].field carries leading or trailing whitespace is refused on its next authoring-path save, with a per-element issue at grouping.fields[N].field naming the offending spelling and the trimmed name to write instead. Names with no padding parse byte-identically to before — nothing is normalised on the way through, and a dotted relationship path stays valid. Views with no grouping block are untouched. Every grouping.fields[].field spelling in this repo at the time of the change parses unchanged: 50 literal occurrences under a grouping: key across 19 files, harvested with the TypeScript parser and cross-checked against 906 shape-exact { field, order?, collapsed? } literals in packages/**. The single harvested spelling this refuses — ' ' at view-grouping-query.test.ts — is a NEGATIVE fixture handed straight to compileListViewGroupQuery with no parse on its path, pinning that same grouping_field_blank refusal; the producer now refuses it one layer earlier for the same reason.
  • ui-mcp-connect-agent-unknown-keys-refused — page mcp:connect-agentcomponent —properties (any key at all: the widget declares no props) → an empty properties bag ({}), or omit properties entirely. The widget reads no prop: the console registration discards the schema node (() => <ConnectAgent />) and the component function takes no parameters — every value it renders comes from /discovery, i18n and its own state — so there is no declared key to move to; a key authored on it configures nothing and is removed, not renamed. Node-level keys (visibleWhen, id, style, …) stay on the component node, where the page runtime reads them.
    • Why not automatic: This was a third instance of the class closed for the record:* blocks by declaring a strict ComponentPropsMap row measured from the renderer's read points (the strict, empty cloud-connection:panel / marketplace:installed-list rows closed the previous two): a console-registered widget on @objectstack/mcp's plugin-shipped Setup page, reachable through the component type union's open string arm, with a registered renderer but no ComponentPropsMap row — so the props gate's dispatch (it parses properties against the type's row, and skips a type with no row because the type union is open) skipped it as unregistered, any authored key rode through every validator in silence, and door 3 of the canonical-envelope gate @objectstack/mcp was given for its shipped page had to carry a standing exemption for the type. The new row is strict and EMPTY, measured from the renderer's actual read points at the objectui pin (not from the registration's declared-input list): the registration ignores the component node entirely, so the widget accepts no configuration at all, and an authored key is now a publish-time refusal naming the surface instead of a silent no-op.
    • Done when: Every mcp:connect-agent node authors an empty (or absent) properties bag and validates clean — the plugin-shipped page (connect_agent) already does; objectstack validate reports no component-props-unknown-key finding for the type. Any remaining authored key on the widget is deleted (it never configured anything), and behaviour that seems to need one is a renderer capability request against objectui, not a metadata key.
  • ui-object-block-grouping-config-typed — the grouping property of the object-grid and object-kanban page blocks (ComponentPropsMap['object-grid' | 'object-kanban'].grouping), which was z.unknown and therefore accepted any value: a padded field name such as { fields: [{ field: ' business_unit ' }] }, a number, a bare field-name string, an empty fields list, or keys the grouping config does not declare → the grouping config a list view carries, GroupingConfigSchema from @objectstack/spec/ui: { fields: [{ field, order?, collapsed? }, ...] } with at least one entry, each field naming the record field exactly as it is stored, with no leading or trailing whitespace, order one of asc / desc and collapsed a boolean. A padded name is rewritten unpadded (' business_unit ' becomes 'business_unit'); a bare string 'business_unit' becomes { fields: [{ field: 'business_unit' }] }; an empty fields list, a number, or any other value is deleted, since it never grouped anything. On object-kanban, swimlaneField still wins when both are authored, and deleting grouping is the whole migration there when swimlaneField is set
    • Why not automatic: Both blocks' renderers read the list view's grouping shape and nothing else: the grid groups its rows by every grouping.fields[i].field (its server-side group header query and its row projection) and reads order and collapsed per level, and the kanban board takes grouping.fields[0].field as its swimlane field when no swimlaneField is authored, looking that raw name up on every card. The list view has refused a padded grouping field name since protocol 17.5, and a list view's grouping is a closed shape; these two doors declared the same prop as z.unknown, so the same value that list view refuses validated green here and rendered wrong with no error — the grid showed one (empty) group holding every row, the board one swimlane holding every card. The prop is kept, not retired: the board's fallback is a live reader of the grouping config. The rewrite is left to the author on purpose: a trimming rule would make a padded and an unpadded name silently equivalent, which is the consumer tolerance the contract refuses, and a bare string, a number or an empty list has no mapping that says what grouping was meant. Metadata AT REST is left exactly as stored — properties on a page component is not parsed on the save path, so a stored page keeps loading and renders as it does today; the component-props gate reports such a value as an advisory component-props-invalid finding at the offending path on os validate, os build and os lint, a padded name with the received value and the unpadded name to write. ADR-0049 / ADR-0087.
    • Done when: Every object-grid and object-kanban node in your pages either omits grouping or carries { fields: [...] } with at least one entry whose field is the unpadded stored name. os validate reports no component-props-invalid finding under properties.grouping for these blocks. A well-formed grouping parses byte-identically to before when order and collapsed are spelled out; a short entry { field } parses clean and gains the list view's defaults (order: asc, collapsed: false), which is how the grid already read it. After the rewrite, a grid that showed one (empty) group shows one group per value of that field, and a board that showed one swimlane shows one swimlane per value — check that this is the grouping you meant.
  • ui-object-form-custom-fields-typed — page object-formcomponents —properties.customFields (which used to accept any value) → a list of closed inline form fields { name, label?, type?, required?, options?, … } — the members the form draws, in camelCase, each option { label, value, description?, visibleWhen? } with value a string, a number or a boolean. Write a visibleOn (or a legacy condition) as visibleWhen, move a member's defaultValue into the block's initialValues, drop id, and leave the grid widget's snake_case keys (min_rows, allow_add, …) out until the widget reads a camelCase spelling.
    • Why not automatic: The form merges customFields over the fields it generates from the object's metadata — a member naming a declared field replaces that field's whole definition, any other is added — and draws each member as it was written, handing it to the field widget as its metadata. The page-component row declared it z.unknown(), so 42, a member with no name, or a misspelled member passed the component-props gate, and the form drew the field without it. The row now takes a closed runtime form field of the members the form draws, keyed by name, each typed to its read — by reference where this package already declares the member (the object field's metadata members, the evaluated predicates). An option is the runtime option the form's option controls draw — label, value, description, visibleWhen — and its value is any string, number or boolean, kept as written: an inline field binds no object column, so a stored field's lowercase identifier rule does not apply to it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working member to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-form node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.customFields. Each form draws every inline field with the label, type and rules its member names.
  • ui-object-form-fields-names-typed — page object-formandobject-master-detail-formcomponents —properties.fields (whose entries used to accept any value) → a list of bare field names, in the order the form draws them. Write a { name: 'email' } entry as 'email' — the form only ever drew its name — and move a label or required override onto a sections[].fields entry (type is always the object field's); write a { field: 'email' } entry as 'email', or move it into a section's fields, the vocabulary it belongs to.
    • Why not automatic: The form reads its top-level fields as the names of the fields to draw, in order, selecting from the object's fields and from customFields; the master-detail form hands its own to the parent form verbatim. objectui declares the member string[], but the page-component rows declared it z.array(z.unknown()) while the form drew a { name } entry by that name — the shape objectui's page-builder guide taught, with a label, type and required the form silently dropped. objectui has since retired that entry from every authoring face — the guide and its fixtures name the fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a { name } entry is its bare name, and a { field } entry — the sections[].fields vocabulary, which the form skips at the top level with a console warning — is its bare name or belongs in a section. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, the form already draws a stored { name } entry by its name, and an override written beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-form and object-master-detail-form node validates: objectstack validate reports no component-props-invalid finding under properties.fields. Each form draws the fields its list names, in that order, with any per-form label or required override taken from its section entry.
  • ui-object-form-members-typed — page object-formcomponents —properties.contentLayout, .submitBehavior, .navigateOnSuccessand.mobile (which used to accept any value) → the shape the form reads: contentLayout 'simple' or 'tabbed'; submitBehavior the form view's own block — { kind: 'thank-you', title?, message? }, { kind: 'redirect', url, delayMs? } with a relative url, { kind: 'continue' } or { kind: 'next-record' }; navigateOnSuccess a relative path string; mobile { stickyActions?, stepper?, stepperMinFields?, stepperFieldsPerStep?, fullscreenLongText? }, with stepper true, false or 'auto' and the two counts positive integers. Write a submitBehavior kind as one of the four; move a redirect destination to a relative path; write heading as title.
    • Why not automatic: The form reads these members with one shape, and the page-component row declared them z.unknown(), so any value passed the component-props gate and the form answered an off-shape one with a silent default: a submitBehavior kind it does not know fell through to the thank-you panel; a misspelled contentLayout such as 'tabs' stacked the sections; a navigateOnSuccess that is not a string threw after the record was written, so the submit reported a failure; and a mobile member it does not read, or a stepper outside true / false / 'auto', was ignored. The row now takes the form view's own submitBehavior by reference — the block the renderers already judge a redirect url through — so one value is judged the same way on the form view and the block, and the measured shape for the other three. The form's fields and sections and the master-detail form's two stay open, because the form draws a { name } field entry and an inline runtime field inside a section, which the typed shapes would refuse; and customFields stays open until the spec declares the runtime form field its entries are. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the form shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-form node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under the four members' paths. Each form that set one of them now shows it: the post-submit behaviour it names, the modal's tabbed sections, the navigation after a save, and the phone presentation.
  • ui-object-form-sections-typed — page object-formandobject-master-detail-formcomponents —properties.sections (whose entries used to accept any value) → closed sections { name?, label?, description?, collapsible?, collapsed?, visibleWhen?, columns?, pane?, fields } (or { group, columns?, pane? }), each fields entry a field name, the form view's { field, … } entry or an inline form field { name, type, … }. Write a section or field visibleOn as visibleWhen, a string columns: '2' as the number 2, and a section label (or a field entry's label / placeholder / helpText) as a plain string.
    • Why not automatic: The form reads a section's heading, collapse pair, visibleWhen, columns, pane, group and fields — the key set of the form view's section — and draws three kinds of field entry: a name, the form view's { field } entry overriding that object field, and an inline runtime form field drawn as it stands. The page-component rows declared each section z.unknown(), so a misspelled key passed the component-props gate and the form drew the section without it; a form view's deprecated visibleOn and string columns, which a form view folds at parse, reached the form raw — a page block's properties is never parsed on the way — and were dropped. Both rows now take one section shape of their own, the stored form view unchanged: the form view's section keys plus the three entry arms, canonical spellings only, a label a plain string because the form draws it as it stands, and the form view's group-reference rule. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census, with every inline option list evaluated, found no working section to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-form and object-master-detail-form node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.sections. Each form draws every section with the heading, visibility and columns it names, and every entry in it.
  • ui-object-gantt-markers-typed — page object-ganttcomponents —properties.markers (whose entries used to accept any value) → a list of { date, label?, color? }: date an ISO date or date-time string (required), label the text drawn against the line, color any CSS colour. Write a marker title, text or name as label, and a colour as color; give every marker a string date.
    • Why not automatic: The gantt reads each marker with one shape — date places the line, and a date that does not parse or falls outside the drawn range draws none; label is drawn against it; color paints it, the theme's primary colour when absent — and the page-component row declared the entries z.unknown(), because that contract was objectui's alone. So a marker with no date, a numeric date or a misspelled member passed the component-props gate, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker, { date, label?, color? } with date a string (authored metadata is JSON, which cannot carry a Date), closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a misspelled member has no rewrite that says which member the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-gantt node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.markers. Each gantt that sets markers draws one line per marker whose date falls in the drawn range, with the label and colour written.
  • ui-object-grid-columns-typed — page object-gridcomponents —properties.columns (whose entries used to accept any value) → the list view's own columns: all field-name strings, or all column entries { field, label?, width?, align?, hidden?, sortable?, resizable?, wrap?, type?, pinned?, summary?, prefix?, link?, action? }. Respell a column keyed accessorKey / header or name as field / label; write a list as all strings or all entries, never a mix; delete a column key the entry does not declare (editable, options, reference, currency, precision, …) — inline editing is the grid's own editable, and option labels, relational metadata and number formats are the object field's.
    • Why not automatic: The grid reads columns with one shape — all field-name strings or all column entries, decided by the first entry, drawing only an entry with a string field and reading the column entry's own members off it — and the page-component row declared it z.array(z.unknown()), so any entry passed the component-props gate and the grid answered an off-shape one in silence: a column keyed accessorKey / header or name, or one with no field, drew no column, a mixed list lost every entry the first one did not match, and a key the grid never reads off a column (editable, options, reference) was ignored. The member was held while the grid's group headers drew a column's options ahead of the field's; the renderer has since retired that read and takes the labels from the object field only, so the row takes the list view's own columns by reference — the column entry a list view already refuses an undeclared key on. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a refused column has no rewrite that both keeps what the grid draws today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-grid node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.columns. Each grid draws every authored column: one per entry, headed by its label or the field's own, in the order written.
  • ui-object-grid-export-options-closed — page object-gridcomponents —properties.exportOptions (which used to accept any value) → the export options object a list view's exportOptions declares: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }, with formats drawn from csv, xlsx and json, maxRecords a non-negative integer, and includeHeaders / streaming booleans. Where a bare format array was written, write { formats: [...] } to offer the formats you listed — the grid will now offer exactly those — or {} to keep the csv/json default the grid has been offering. Delete pdf from formats, and any key the object does not declare; delete an exportOptions: null (it never enabled the menu).
    • Why not automatic: The grid reads one export options block — exportOptions.formats, .maxRecords, .includeHeaders, .fileNamePrefix and .streaming — the block a list view declares, but the page-component row declared the key z.unknown(), so any value passed the component-props gate. The trap was the list view's legacy spelling: a bare format array is legal on a list view, which lifts it to { formats } at parse, and was accepted on the grid, which lifts nothing — the export menu appeared, offering the csv/json default, and the author's list was dropped without a report. The row now takes the list view's export options object itself rather than its union, so a legacy spelling does not spread to a surface that never read it: a bare array is refused with the object form named, a format outside the enum is refused at its index (pdf with its retirement text), and a key the object does not declare is named. It is read where every page component's props are: the component-props gate reports these as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape; a bare array has no rewrite that both keeps what the grid shows today and honours what the author wrote, which is the judgment this entry leaves to the upgrader; and the authored census found nothing to respell. Population measured at the change, on origin/main f148852752: zero object-grid blocks authoring exportOptions in the examples, the package fixtures, the documentation and the published skills, against ten authored object-grid blocks through the same matcher (nine in TypeScript, one in a YAML documentation example) and four list-view exportOptions authorings as the key's control. Deployed metadata NOT MEASURED.
    • Done when: Every object-grid node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding on a properties.exportOptions path. Every exportOptions on an object-grid is an object carrying only the five declared keys, with every formats entry csv, xlsx or json, and the grid's export menu offers the declared formats the active export path delivers (xlsx on the server stream only).
  • ui-object-grid-kanban-calendar-list-members-typed — page object-gridcomponents —properties.fields, .selection, .selectable, .rowActions, .bulkActionsand.batchActions; page object-kanbancomponents —properties.columns; page object-calendarcomponents —properties.calendar (which used to accept any value) → the shape each block reads, the list view's own where it has one: object-grid fields field-name strings; selection { type } with none / single / multiple; selectable true, false, 'single' or 'multiple'; rowActions, bulkActions and batchActions action-name strings. object-kanban columns all lanes { id, title, cards?, limit?, className?, collapsed? } or all bare value strings (never mixed), a lane id a string. object-calendar calendar { startDateField, endDateField?, titleField?, colorField?, allDayField? }. Move an object entry of fields to columns; move a { name } entry of bulkActions to bulkActionDefs or write the bare name; style a lane with className instead of color; rename dateField / endField to startDateField / endDateField.
    • Why not automatic: Each renderer reads these members with one shape, and the page-component rows declared them z.unknown(), so any value passed the component-props gate and the block answered an off-shape one with a silent default: an object entry of fields named no field; a { name } entry of bulkActions was skipped; a kanban lane list mixing objects and strings drew a blank lane and swept its records into the trailing lane; and a calendar block without startDateField placed no event. The rows now take the list view's own selection, rowActions, bulkActions (for batchActions too, the spelling the grid reads first) and calendar members by reference, and the measured shape for the grid's fields and selectable and the kanban lane, so one value is judged the same way on every door that carries it. The grid's columns is not narrowed: its group-header labels read an authored column's options, which the list view's column entry does not declare, so it stays open until that read is ruled. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-grid, object-kanban and object-calendar node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under the eight members' paths. Each block that set one of them now shows it: the grid's field fallback, the selection mode, the row and bulk actions, the kanban lanes with their records, and the calendar events placed by startDateField.
  • ui-object-grid-page-size-positive-integer-refused — ``object-grid page-component page sizes (ComponentPropsMap['object-grid']—pagination.pageSize, each pagination.pageSizeOptions[]entry, and the flatpageSize shorthand) — zero, negative and non-integer values (pagination: { pageSize: 0 }, pageSize: 25.5) → a positive integer, or no declaration at all. A page size of 0 has no defined meaning on this surface and never had one: delete the key to take the renderer's own default, or write the page size that was meant (pageSize: 0 authored to mean "no paging" is showPagination: false with no pagination bag, since the bag's PRESENCE is what enables paging)
    • Why not automatic: This door still carried the shape it was given when the object-* blocks first got ComponentPropsMap rows measured from their read points — pagination: z.unknown() and pageSize: z.number() — after the view arm converged on z.number().int().positive(). So the SAME authored member carried two accept sets and renderers read the looser one: PaginationConfigSchema (view.zod.ts) refuses pageSize: 0 and pins that refusal by name, and every other pageSize the package declares is bounded with its own throwing pin (kernel/metadata-plugin.zod.ts, marketplace/marketplace.zod.ts) — the component arm was the only one that accepted 0. The value is LIVE: an objectui grid measurement found that an authored pagination.pageSize: 0 reached ObjectGrid, went out on the wire as $top: 0 and rendered ZERO ROWS, with no grouping needed to trigger it, and it reached the renderer through this arm. objectui's grid plugin repaired the consumer half — it now refuses a non-positive page size at all three read points (one resolver, fail-soft, one loud diagnostic); this is the declaration half, and it is not a prerequisite for that repair. ⚠️ The pagination bag itself stays OPEN (z.looseObject): only the two members whose value is a page size are bounded, and sibling keys parse and pass through exactly as before. PaginationConfigSchema on the view arm is a closed shape and is unchanged by this entry.
    • Done when: Every object-grid node declaring a page size — inside pagination or through the flat shorthand — carries a positive integer. Well-formed values (10, 25, 50) parse byte-identically to before, a pagination bag carrying sibling keys parses and keeps them, and absence stays absence. A stored page whose object-grid node carries pagination: { pageSize: 0 } still saves and loads — properties on a page component is not parsed on the metadata save path — and the component-props gate reports it as an advisory component-props-invalid finding at pagination.pageSize on os validate, os build and os lint; a pageSizeOptions entry and the flat pageSize shorthand are reported the same way at their own paths. The author deletes the key or writes the page size they meant, and os validate then reports no component-props-invalid finding for that node.
  • ui-object-grid-row-members-typed — page object-gridcomponents —properties.rowHeight, .rowColor, .navigation, .conditionalFormatting, .bulkActionDefs, .aggregationsand.operations (which used to accept any value) → the shape the grid reads, the list view's own where it has one: rowHeight one of compact / short / medium / tall / extra_tall; rowColor { field, colors }; navigation { mode?, size?, openNewTab?, preventNavigation? }; conditionalFormatting [{ condition, style }] with a CEL condition and a CSS style map; bulkActionDefs the list view's bulk-action defs; aggregations [{ field, type }] with type one of count, sum, avg, min, max, count_distinct; operations { create?, update?, delete?, export? } booleans. Rewrite an objectui-native formatting rule { field, operator, value, backgroundColor } as { condition: "record.FIELD == VALUE", style: { backgroundColor } }; delete operations.read and operations.import, which nothing reads.
    • Why not automatic: The grid reads each of these members with one shape, and the page-component row declared them z.unknown(), so any value passed the component-props gate and the grid answered an off-shape one with a silent default: an off-preset rowHeight such as 42 rendered as compact, a rowColor of the wrong shape coloured no row, a navigation written as a bare mode string opened the record page whatever it named, an aggregation with an unknown function drew a zero nothing computed or no number at all, and an operations toggle nothing reads toggled nothing. The row now takes the list view's own schemas for the five members a list view declares, and the measured shape for aggregations and operations, so one value is judged the same way on both doors. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the grid shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-grid node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under the seven members' paths. Each grid that set one of them now shows it: the declared row height, the row colours its colors map names, the navigation mode on a row click, the conditional styles, the bulk actions, the group-header numbers and the affordances operations names.
  • ui-object-kanban-conditional-formatting-typed — page object-kanbancomponents —properties.conditionalFormatting (which used to accept any value) → the list view's own rules, [{ condition, style }]: a non-blank CEL condition over the card's record.* and a CSS style map of string values. Rewrite a native rule { field, operator, value, backgroundColor } as { condition: "record.FIELD == VALUE", style: { backgroundColor } }, an expression as condition, and move a colour written beside condition into style.
    • Why not automatic: The board reads conditionalFormatting as an ordered list of { condition, style } rules, through the evaluator the grid's rows use, and paints a card with the style of the first rule whose condition holds; objectui declares exactly the list view's rule as the member's only dialect. The page-component row declared it z.unknown(), so 42, a bare string or a rule with no style passed the component-props gate and the board painted no card for it. The row now takes the list view's own member, by reference, as object-grid does, so one rule is judged the same way on every door. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no working rule to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-kanban node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.conditionalFormatting. Each board that sets rules paints the card each rule names with its style.
  • ui-object-map-gantt-tree-navigation-typed — page object-map, object-ganttandobject-treecomponents —properties.navigation (which used to accept any value) → the list view's navigation block { mode?, size?, openNewTab?, preventNavigation? }, mode one of page, drawer, modal, split, popover, new_window, none — the block object-grid, object-kanban, object-calendar and object-timeline already take. Rewrite a bare mode string such as navigation: 'drawer' as navigation: { mode: 'drawer' }.
    • Why not automatic: Each of the three renderers hands navigation to the shared navigation hook, which reads navigation.mode, falls back to page when it finds none, and types its mode union as the list view's NavigationConfigSchema. The rows declared the member z.unknown(), so any value passed the component-props gate and an off-shape one was answered with a silent default: navigation: 42 and a bare mode string such as 'drawer' both opened the record page, whatever they named. The three rows now take the list view's schema by reference, so one value is judged the same way on every door that carries it. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-map, object-gantt and object-tree node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.navigation. Each block that set navigation opens the mode it names on a marker, task or row click.
  • ui-object-master-detail-form-details-closed — page object-master-detail-formcomponents —properties.details[](each detail entry, which used to accept any value) andproperties.details[].columns[](its inline grid columns), includingscaleon a column that declares notypeand whosenameis acurrencyfield of the entry'schildObject`` → each entry is { childObject, relationshipField?, columns?, formFields?, inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? } — the keys the renderer reads — with inlineMode one of grid / form. Each column is the strict, name-keyed inline grid column a relationship field's inlineColumns takes — { name, label?, type?, … }, where { name } alone hydrates the rest from the child object's field. Write childObject on every entry; write name where a column said field (or fieldName, key) or was a bare field-name string; delete scale from a column that renders as a currency column, whether it declares type: 'currency' or takes it from a currency child field — nothing replaces it, the currency's ISO 4217 minor unit decides; delete any key neither shape declares.
    • Why not automatic: The block draws one inline grid per detail entry, hydrating an authored column list with the same rule and into the same grid as the other two carriers of the inline grid column, but nothing judged its entries: a key the renderer does not read was ignored in silence, and a column carrying a key the grid does not read, or scale on a currency column — refused on the other carriers under the maintainer's rulings of 2026-09-23 (option B, scale retired from the currency type) and 2026-09-24 (option 乙 — a currency's ISO 4217 minor unit decides its display) — went through objectstack validate green. The entry is now a strict shape and its columns references the column schema, so every rule that schema holds applies here too, with its own prescription. The entry half is read where every page component's props are: the component-props gate reports a failing entry or column as an advisory component-props-unknown-key / component-props-invalid finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. The identity-only half is defineStack's cross-reference check, which already judged the other two carriers: it now reaches the block wherever a page carries it and refuses an identity-only column over a currency child field that carries scale, with the column schema's own message; reach: the child object must be declared in the same stack, and a column the column schema refuses on its own is left to the component-props gate. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Population measured at the change, on origin/main ebdb6f2aca: one authored block in the examples (the showcase project workspace, one entry { title, childObject, addLabel }, no columns), one documentation example whose three columns were bare field-name strings (rewritten as { name } columns in the same change), and zero field-keyed detail columns, against one authored inlineColumns block as the control. Deployed metadata NOT MEASURED.
    • Done when: Every object-master-detail-form node validates: objectstack validate reports no component-props-unknown-key / component-props-invalid finding on a properties.details path and no cross-reference finding on a details[].columns[].scale path. Every detail entry carries childObject and only keys the entry shape declares; every column is an object carrying name, no column carries field, fieldName or key, and no column that renders as a currency column carries scale. The block's showcase entry { title, childObject, addLabel } parses unchanged, and the master-detail grid renders a value — not a blank cell — in each authored column for a row that has one.
  • ui-object-metric-aggregate-trend-typed — page object-metriccomponents —properties.aggregateand.trend (which used to accept any value) → the shape the tile reads: aggregate { field?, function, groupBy? }, with function one of the engine's count, sum, avg, min, max or count_distinct, a field for every function but count, and groupBy the chart aggregate's own union — a field name or a { field, dateGranularity?, alias? } date-bucket node — here optional; trend { value, label?, direction? }, with value a number, label a string or an inline locale map and direction up, down or neutral. Write a string aggregate as an object ('count' → { function: 'count' }); move dateGranularity inside groupBy; write a bare trend direction as { value, direction }.
    • Why not automatic: The tile reads these members with one shape, and the page-component row declared them z.unknown(), so any value passed the component-props gate and the tile answered an off-shape one in silence: a string aggregate or a function the engine does not have asked the server for a measure it does not have, so the tile showed an error or, on the client-side fallback, a sum it was not asked for; groupby for groupBy drew one ungrouped number; and a trend with no value painted a lone %, with a misspelled member or direction simply not drawn. The row now takes the query AST's own aggregation functions — the six the tile forwards to the engine — and the chart aggregate's groupBy union by reference, and the badge's measured shape for trend. The aggregate is not the chart's whole: the chart requires groupBy and five functions, while a metric paints one number over every row and draws a count_distinct wherever the analytics service answers it. drillDown and compareTo stay open: the chart's drill-down declares a filter the tile never reads and refuses a report it draws, and the dashboard widget's comparison declares a dimension this path never reads, so each waits on a ruling between the reference and the read. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the tile shows today and honours what the author wrote — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-metric node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under the two members' paths. Each tile that set one of them now shows it: the number its aggregate names, grouped or bucketed as written, and the trend badge with its value, arrow and caption.
  • ui-object-metric-compare-to-typed — page object-metriccomponents —properties.compareTo (which used to accept any value) → the shape the tile reads: { kind }, with kind the dashboard widget comparison's own vocabulary, previousPeriod or previousYear. Write a bare kind string as an object ('previousYear' → { kind: 'previousYear' }), and delete a dimension: the tile shifts the date macros in its own filter, so state the window there.
    • Why not automatic: The tile reads compareTo with one shape — kind alone, dispatching on previousYear and treating every other value as previousPeriod — and the page-component row declared it z.unknown(), so any value passed the component-props gate and the tile answered an off-shape one in silence: a bare 'previousYear' or a kind outside the two compared against the previous period, and a dimension was carried and never read, because this inline tile shifts the date macros in its own filter while only a dashboard widget's dataset path hands dimension to the analytics executor. The row now takes { kind }, with kind the dashboard widget comparison's own member by reference, and refuses dimension by name with that prescription rather than accepting a key the tile ignores. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a dimension has no rewrite that keeps the window the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-metric node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.compareTo. Each tile that sets a comparison shows its trend labelled for the kind it names, over the window its own filter resolves to.
  • ui-object-metric-drill-down-report-typed — page object-metriccomponents —properties.drillDown.report (which used to accept any value) → a report definition, the same shape as reports[] (ReportSchema): { name, label, dataset, values, … }, or a joined report whose every block binds a dataset. Write a bare report name, a { name } reference or the retired objectName / columns form as the dataset-bound report itself.
    • Why not automatic: The metric tile hands drillDown.report to the shared drill drawer, which draws it as a report — with the metric's filter joined into the report's own runtimeFilter — when it is dataset-bound (a non-empty dataset, or a joined report with a block that binds one), and lists the records for any other value. The page-component row declared it z.unknown(), so a report with no dataset, a misspelled report key, a bare report name or a { name } reference passed the component-props gate, and the drawer quietly listed the records instead. The row now takes ReportSchema by reference — the declaration objectui already names for the member — and, since a joined report refuses a block that binds no dataset, every report it admits is one the drawer draws. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn report to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-metric node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.drillDown.report. Each tile whose drill names a report opens that report, scoped by the metric's filter, instead of the record list.
  • ui-object-metric-drill-down-typed — page object-metriccomponents —properties.drillDown (which used to accept any value) → the shape the tile reads: { enabled?, title?, target?, columns?, maxRows?, report? }, the first five the chart drill-down's own members — enabled a boolean, title a string, target drawer, dialog or navigate, columns field names, maxRows a positive whole number — and report still open. Delete a drill filter and scope the metric with its own filter, one level up; delete a mode, since a metric always lists the records behind its number.
    • Why not automatic: The tile reads drillDown with one shape — enabled, title, target, columns, maxRows and report, scoping the drilled list by the metric's own filter — and the page-component row declared it z.unknown(), so any value passed the component-props gate and the tile answered an off-shape one in silence: a drill filter or a mode was carried and never read, a misspelled member was simply not applied, and a non-numeric page size reached the drilled list. The row now takes the five list members the chart drill-down declares, by reference, and refuses filter and mode by name: a metric tile has no click event for a drill filter to resolve against, and no row for mode to open as a record. The chart's shape is not taken whole, because it declares filter. The drill report stays open: the tile draws a dataset-bound report through the shared drawer, but no spec drill shape declares a report member yet. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and a drill filter has no rewrite that keeps the scope the author meant — which is the judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.
    • Done when: Every object-metric node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.drillDown. Each tile that sets a drill-down opens it as written: the panel shape target names, the heading title names, and the records behind the number, scoped by the metric's own filter, in the columns and page size written.
  • ui-object-timeline-items-typed — page object-timelinecomponents —properties.items (whose entries used to accept any value) → the entry kind the block's variant selects: on vertical (the default) or horizontal, a feed entry { time?, title, description?, variant?, icon?, content?, className? }; on gantt, a gantt row { label, items? } whose bars are { title?, startDate?, endDate?, variant? }, each date a string or epoch milliseconds. Write a feed entry's date as time and its color as variant (default, success, warning, danger, info); move a gantt row to variant: 'gantt', or a feed entry off it.
    • Why not automatic: The timeline rail draws items as authored, ahead of every record source, and each branch of its renderer reads only its own kind of entry: the feed branches read time, title, description, variant, icon, content and className; the gantt branch reads a row's label and its bars' title, startDate, endDate and variant. The page-component row declared each entry z.unknown(), so a misspelled key, a feed entry with no title, or a gantt row on a feed timeline passed the component-props gate, and the rail drew an empty, unlabelled entry. The row now takes objectui's two ruled kinds, closed, and pairs each entry with the kind its variant selects; a feed entry's content (child components) is held unjudged until a writer appears. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found no drawn entry to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-timeline node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.items. Each timeline with authored entries draws every entry with its title (or row label), date and colour.
  • ui-object-timeline-mapping-typed — page object-timelinecomponents —properties.mapping (which used to accept any value) → the binding record the rail reads: { title?, date?, description?, variant? }, each a field name. Write titleField, dateField / startDateField, descriptionField and variantField inside mapping as title, date, description and variant; write a bare field name as the member it binds (mapping: { title: 'subject' }).
    • Why not automatic: The timeline rail reads mapping as four field names — title and date between the timeline block's own member and the flat fallback, description ahead of descriptionField, and variant, the field whose value picks each entry's marker colour and the one binding with no other spelling — and the page-component row declared it z.unknown(), because that contract was objectui's alone. So a bare field name, a non-string binding or a misspelled member passed the component-props gate, and the rail bound nothing for it and drew the default field. The spec now declares objectui's own declaration of the binding record, four optional field names, closed as every element shape on that map is. It is read where every page component's props are: the component-props gate reports a refused value as an advisory component-props-invalid / component-props-unknown-key finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. No conversion is registered: nothing on the load path refuses the shape, and the authored census found nothing to respell. Deployed metadata NOT MEASURED.
    • Done when: Every object-timeline node validates: objectstack validate reports no component-props-invalid / component-props-unknown-key finding under properties.mapping. Each timeline that sets a mapping draws its entries' title, date, description and marker colour from the fields it names.
  • ui-react-list-view-binding-aliases-retired — ``kind:'react'page source —and (the react-tier overlay aliases published as deprecated when the react tier converged on the metadata-tier vocabulary) → <ListView data={{ provider: 'object', object: '…' }} type="…"> — ListViewSchema's own data data source and type view kind, the same two keys a metadata list view authors. objectName="x" → data={{ provider: 'object', object: 'x' }}; viewType="kanban" → type="kanban". A <ListView> with no data at all is refused too: on a react page no host stamps the object, so the data source is the required binding there.
    • Why not automatic: A react page's source is a JSX string, not a keyed document: objectstack migrate meta rewrites stored metadata by key and cannot rewrite props inside authored source, so the move is by hand. The contract deprecated both aliases in favour of the metadata-tier spelling (maintainer ruling 2026-08-23: the react tier converges on the metadata-tier vocabulary, deprecating first) while objectui's ListView still read only objectName, so the canonical spelling validated green and rendered an empty list. The consumer fold has landed (objectui normalizeListViewSchema, console pin a472b071: data.provider === 'object' → objectName, and the author's type read for the view kind), and the maintainer ruled the aliases retired with no deprecation window (2026-09-07). Writing either alias is now a publish-time react-prop-retired error carrying this prescription — never a silent pass on a key the renderer happens to still read.
    • Done when: objectstack validate reports no react-prop-retired and no react-prop-missing-required finding on any kind:'react' page; every <ListView> carries data={{ provider: 'object', object }} (or another ViewData provider) and, where a view kind was chosen, type; in the console each rewritten list renders the same rows and visualization it rendered under the alias spelling. The platform's own sites are the reference: the showcase crm-workbench, renewals-pipeline and task-desk pages pass with zero findings.
  • ui-record-blocks-unknown-keys-refused — page record:alert/record:quick_actions/record:history/record:discussioncomponents —properties(undeclared keys, notably a typo'dseverty, quick_actions' inline actions, history's host-channel entries/loading, and any key at all on record:discussion) → the declared shapes the renderers read. record:alert: severity?, title? / body? (string or inline locale map), visible? (boolean | CEL string | { dialect, source }), icon?, action? { actionName, label?, variant? }, dismissible?, dismissKey?. record:quick_actions: actionNames?, requiredPermissions?, location? (the spec's own action-location vocabulary), align?, inline?, variant? / size? (the Button primitive's vocabulary). record:history: limit?, emptyText? / unknownUserText? (literal strings). record:discussion: record:chatter's own row — one schema for the pair. Every rejection carries the surface, the offending key and a prescription (actions → actionNames; entries / loading → omit, the block self-fetches sys_activity; aria on quick_actions → not declared until the renderer reads the contract spelling; visibleWhen / visibility on the alert → visible; a locale map as history text → a literal string)
    • Why not automatic: These were the four record:* components the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's properties, by parsing them against the type's ComponentPropsMap row) could not reach after the rail was given its strict row: each had a registered objectui renderer (and, bar record:discussion, a PageComponentType entry and a console palette slot) but no ComponentPropsMap row, so the props gate's dispatch skipped them as unregistered and every authored key rode through. A typo'd severty on the platform's own banner surface parsed, typechecked, validated, built and shipped as a silent no-op while sibling components in the same file drew loud diagnostics. The rows declare the shapes the renderers actually read (measured from read points at the objectui pin, not from the registrations' declared-input lists — quick_actions' registration claims an empty-bar fallback the renderer does not implement, and omits the aria.label read that exists but under a spelling the shared ARIA shape refuses), so an undeclared key is now a publish-time refusal instead of a silent no-op.
    • Done when: Every record:alert / record:quick_actions / record:history / record:discussion node validates with only declared keys, and declared keys parse byte-identically to before — the platform sys_user page's banner (inline locale maps, CEL visible, CTA) and self-service quick_actions bars, and the showcase task page's banner and bar, all pass with zero findings; objectstack validate reports no component-props-unknown-key / component-props-invalid finding for these types. The one parse-time normalization is ExpressionInputSchema's own: a bare-string visible becomes the canonical { dialect: 'cel', source } envelope.
  • ui-record-line-items-props-closed — page record:line_itemscomponents —properties(which used to accept any key) andproperties.columns[] (its inline grid columns) → the declared shape the renderer reads: { childObject?, relationshipField, columns, parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, maxRows?, filter?, sort?, limit? }, with filter the ViewFilterRule array, sort the SortItem array and limit a positive integer; childObject may come from the component-level dataSource binding instead. columns is required and holds at least one column, each the strict, name-keyed inline grid column a relationship field's inlineColumns takes — { name, label?, type?, options?, … }. Write name where a column said field (or fieldName, key); declare label, type and options on the column, because this block draws a column exactly as declared and hydrates nothing from the child object's field; delete scale from a column declaring type: 'currency'; delete addLabel, formFields and inlineMode, which belong to an object-master-detail-form detail entry and are not read here, sortField, which no block takes (the detail entry derives the line-position field from the child object), and any other key the shape does not declare.
    • Why not automatic: The block draws one inline grid of the record's child rows, through the same objectui grid as the other three carriers of the inline grid column, but it had no ComponentPropsMap row: it was the one entry on the string-arm registration ledger, so the component-props gate skipped it as unregistered and every authored key rode through. The showcase project page keyed all five of its columns field, the spelling the grid retired, and published green; the grid binds a column by name, so every cell rendered empty. The row is measured from the renderer's read points at the objectui pin, not from the registration's declared-input list, and its columns references the column schema, so every rule that schema holds applies here too, with its own prescription. It is read where every page component's props are: the component-props gate reports a failing key or column as an advisory component-props-unknown-key / component-props-invalid finding on objectstack validate, objectstack build and objectstack lint, and a stored page still saves and loads, because a page component's properties is not parsed on the metadata save or load path. defineStack's identity-only column check does not reach this block: the panel hands its columns to the grid as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the load path refuses the shape, and the one field-keyed producer was respelled in the same change. Population measured at the change, on origin/main 1ecb871beb: one authored block in the examples (the showcase project detail page, five field-keyed columns, respelled name), zero in the documentation, against eight authored record:* blocks of other types through the same matcher as the control. Deployed metadata NOT MEASURED.
    • Done when: Every record:line_items node validates: objectstack validate reports no component-props-unknown-key / component-props-invalid finding on its properties path. Every node carries relationshipField and at least one column, every column is an object carrying name, no column carries field, fieldName or key, and no key outside the declared shape is present. The showcase project detail page's block parses with its five name-keyed columns, and its grid renders a value — not a blank cell — in each column for a row that has one.
  • ui-reference-rail-unknown-keys-refused — page record:reference_railcomponents —propertiesand eachentries[]item: undeclared keys (notably a per-entryfilter, an entry icon, and an inline locale map as title) → the declared shape the renderer reads: entries[] of { objectName, relationshipField, title?, limit?, displayField? } plus a component-level hideEmpty. Every rejection carries the surface, the offending key and a prescription (filter → remove it, or use record:related_list whose filter is real; icon → remove it, no render path reads it; entry-level hideEmpty → move it up beside entries; items / related → entries; object → objectName; label → title; a title locale map → a literal string, or omit it to keep the localized object label)
    • Why not automatic: The rail was the record:* component the component-props unknown-key gate (an authorable surface refuses a key it does not declare; for a component's properties, by parsing them against the type's ComponentPropsMap row) could not reach: it had a registered renderer and a PageComponentType entry but no ComponentPropsMap row, so the props gate's dispatch skipped it as unregistered and every authored key rode through. Measured on 17.0.0 GA end to end: a planted entry filter passed tsc, objectstack validate and objectstack build, shipped verbatim in the artifact, and the rendered rail kept counting and listing unfiltered rows — while the same build loudly reported record:related_list keys in the same file. The row declares the shape the renderer actually reads (measured from its read points, not its TS interface — the interface's icon is read by nothing and is refused, not declared), so an undeclared key is now a publish-time refusal instead of a silent no-op.
    • Done when: Every record:reference_rail node validates with only declared keys: properties carries entries (≥ 1) and optionally hideEmpty; each entry carries objectName and relationshipField and optionally title (literal string), limit (positive int) and displayField. Declared keys parse byte-identically to before; objectstack validate reports no component-props-unknown-key / component-props-invalid finding for the rail.
  • ui-report-joined-block-dataset-required — reports[].blocks[].dataset on a report whose type is joined: a block that binds no dataset (the joined arm of the ReportSchema refinement) → Bind the block to a dataset: set the block's dataset to the dataset whose measures (values) and dimensions (rows) it shows. A block with nothing to show can be deleted instead, as long as the report keeps at least one block.
    • Why not automatic: ADR-0021 single-form, enforced (ADR-0049 enforce-or-remove, the enforce arm). A joined report carries its data on blocks, each an independent query over that block's own dataset, and the container selects nothing: a container dataset is refused. ReportSchema's refinement comment and the reports guide both said each block is dataset-bound, but the joined arm required only that blocks be non-empty, and a block's dataset is optional on its shape, so a block with no dataset parsed, passed objectstack validate and every save door, and drew nothing. Measured at this repo's .objectui-sha pin ab187972159583b595facdcae3c73b50f6f312e9: the joined renderer hands each block's dataset to its table, whose query hook goes idle on an empty name, so an unbound block draws an empty table and issues no query; a report whose blocks all lack one fails the dataset-report guard and falls through to the pre-9.0 presentation bridge, which issues no query either, and a dashboard drill-down that opens that report lists the records instead of drawing it. Studio's report inspector authors blocks through the spec form's blocks repeater, which adds a blank row and requires no column of it, so a block saved with only a name reached the store with no error. The joined arm now refuses each such block at blocks[i].dataset, naming the block, with the prescription to bind it to a dataset. dataset stays optional on the block shape itself: blocks is read only on a joined report, and a block on any other report type is ignored, as before. Ships at once, with no deprecation window: there is no window in which an unbound block draws anything, and there is no mechanical rewrite, because only the author knows which dataset the block was meant to show.
    • Done when: WHICH DOOR: this is the spec schema's refusal, so it lands wherever a report is parsed through @objectstack/spec — defineReport, defineStack, os validate / os build, and the metadata save door (the report entry of the metadata type registry) — as one custom issue per unbound block at blocks.N.dataset, naming the block. A stored sys_metadata report row is not rewritten: it carries the same issue in its read-side _diagnostics and is refused on its next save. Fix each by binding the block to the dataset it is meant to show, or by deleting the block, then check the rendered report: every block queries its dataset and draws its rows. A joined report whose blocks all bind a dataset parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: no joined report with an unbound block in this repository (one example-app report, one docs example and the test fixtures in packages/lint, packages/platform-objects and packages/spec all bind every block; one metadata-door test fixture that left its block unbound on purpose was bound in the same change), in the hotcrm application (one joined report, every block bound) or in the cloud repository (no joined report exists).
  • ui-report-joined-chart-retired — ``report.blocks[].chart(REMOVED from the joined report block shape) andreport.charton a report whosetypeisjoined(REFUSED byReportSchema's refinement) — a chart anywhere on a joined report → nothing on the joined report: a joined report draws each block as a table and has no chart channel at either level. Delete the chart. If the chart was wanted, give the slice it was meant to plot a report of its own — type tabular, summary or matrix, binding the same dataset the block bound, selecting the dimension and measure the chart names in its rows and values — carry the chart over to that report's top level, where xAxis names a dataset dimension and yAxis a measure exactly as before, and reach it from the app navigation beside the joined report.
    • Why not automatic: ADR-0049 enforce-or-remove. Nothing ever drew a chart on a joined report: the renderer's joined branch draws each block as a table and returns before its one read of the report's chart, and no renderer reads a block's chart at all — measured at this repo's .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (DatasetReportRenderer.tsx, joined branch at lines 1462-1524, the only chart read at 1557). So both coordinates parsed, passed the validate-chart-bindings lint (which resolved their axes as if they would plot), and showed tables only. The D2 conversion report-joined-chart-removed already REPAIRS THE DATA: it strips both from authored sources on a chain replay and from stored sys_metadata rows at rehydration, a lossless delete because neither value ever rendered. What it cannot repair is intent. Deleting the key leaves the report looking exactly as it always did — which is the problem when the author believed a chart was there: they were reading a chart that never existed, and only they know whether they wanted one. A walker cannot move it anywhere either: a joined report has no chart channel, and creating a new report, choosing its type and placing it in navigation are authoring decisions, not rewrites. The Studio report form offered a block chart input until this change, so a stored row carrying one is a real shape, not a hypothetical. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything. chart on every non-joined report is unchanged — it is that report's live embedded chart.
    • Done when: WHICH DOOR: the refusal is the spec schema's, so it lands wherever a report is parsed through @objectstack/spec — defineReport, os validate / os build, and the metadata save door (the report entry of the metadata type registry, answered as INVALID_METADATA with status 422). A block chart is refused as an unrecognized key on that block with the upgrade prescription; a container chart on a joined report is one custom issue at chart. (1) No joined report carries a chart at either level: the D2 strip covers existing sources on a chain replay, and the stored-row seams replay it for rows already at rest. (2) For every joined report that carried one, decide whether the chart was wanted; if it was, a non-joined report now binds that slice's dataset and carries the chart, and its xAxis / yAxis resolve (validate-chart-bindings checks them there). (3) Check the rendered joined report: it renders exactly as before, because the chart was never drawn. A joined report with no chart parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports with a chart in this repo's example apps and in the hotcrm reference app, against a lit control (non-joined reports carrying a chart: one and five). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
  • ui-report-joined-container-selection-refused — report selection keys on a joinedcontainer — a top-leveldataset, or a NON-EMPTY top-level rows/columns/valueslist, on a report whosetypeisjoined (ReportSchema's refinement) → the same key on the blocks[] entries that need it — each block binds its own dataset and selects its own rows / columns / values — or DELETE it. Deleting changes nothing that renders: the container value was never read. The refusal lands at the key's own path and says both, the way the container order refusal beside it always has, and that order refusal is unchanged.
    • Why not automatic: ADR-0049 enforce-or-remove, the enforce arm: the four keys stay declared (they are the selection of every non-joined report), and the one report type that never reads them now refuses them. A joined report selects nothing itself, and the refinement already said so for order alone — it refused a container order with a pointer onto blocks[] while the four selection keys beside it parsed green. Measured at this repo's .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52: DatasetReportRenderer's joined branch (DatasetReportRenderer.tsx:1462) reads blocks, plus the container runtimeFilter and drilldown resolved above it, and returns before the top-level reads of columns / dataset / rows / values begin (line 1529 onward) — so each was accepted by the metadata layer and dropped by the renderer without a word. The alias tables made it reachable: fields / measures / metrics route to values, groupings / groupBy / dimensions to rows, and objectName / object / dataSet / source to dataset, on a joined report as on any other. Studio's report inspector hides the top-level binding for a joined report (ReportDefaultInspector.tsx:328) but its type picker patches only type, so a report bound first and switched to joined second carries the keys invisibly. An empty list is NOT refused: it selects nothing, which is what a joined container selects — the container order refusal's own threshold. Ships at once, no deprecation window: there is no window in which a key the renderer never reads does anything.
    • Done when: WHICH DOOR: this is the spec schema's refusal, so it lands wherever a report is parsed through @objectstack/spec — defineReport, os validate / os build, and the metadata save door (the report entry of the metadata type registry) — as one custom issue per key at dataset / rows / columns / values. A stored sys_metadata report row is not rewritten: it carries the same issue in its read-side _diagnostics and is refused on its next save. Fix each by moving the key onto the blocks that need it or deleting it, then check the rendered report: it renders exactly as before, because the container value was never read. A joined report that carries only blocks, runtimeFilter, drilldown and the identity / protection keys parses byte-identically to before, and every non-joined report is untouched. Census at the time of the change: zero joined reports carry any of the four at the container — in this repo one example-app report, one docs example and five test fixtures across packages/lint and packages/platform-objects; in objectui every joined-report fixture and docs example at the pin above (13 occurrences); in the cloud repo none exist.
  • view-chart-binding-dataset-required — A list view whose type is chart and whose effective chart binding names no dataset: no chart block and no options.chart bag, or, on a flattened view overlay saved through the metadata write door, an options.chart bag missing its dataset or its values while no chart block replaces it. Judged at every list-view door: views[].list and views[].listViews, objects[].listViews, a view item config, and the flattened list overlay. → Bind the chart: declare a top-level chart block naming the ADR-0021 dataset to plot and at least one of its measures in values (dimensions, the X / group axis, stays optional, and chartType defaults to bar). A view whose only binding is the legacy options.chart bag completes the bag with dataset and values, or, preferred, moves the binding to the top-level chart block, which replaces the bag whole. A view that is not meant to be a chart takes another type.
    • Why not automatic: ADR-0021 single form, enforced (ADR-0049 enforce-or-remove, the enforce arm; ADR-0078, a view that renders nothing is refused rather than warned). A chart list view plots only the dataset its effective binding names, and the renderer reads that binding as the chart block, else the options.chart bag, the block replacing the bag whole (objectui plugin-list ListView, resolveListChartBinding, at this repo's .objectui-sha pin and at objectui main alike). The authoring chart block already required dataset and values, but a view with no block at all, or with only the bag, never met that schema. Measured on origin/main at e148ca98: the flattened overlay member accepted type: 'chart' with no block and an options.chart bag holding only chartType, and both authoring doors accepted the block-less view. What such a view rendered was a dead screen: at the pin the renderer fabricated a binding nobody wrote (an aggregate over a field named name and a measure named value), and objectui has since retired that floor, after which the chart component refuses on screen. Now refused at the view's own path, chart, or at options.chart.dataset / options.chart.values, with the binding to declare. Ships at once, no grace window and no dual spelling (2026-08-27 maintainer ruling 「短期不考虑渐进」). Not convertible: only the author knows which dataset a chart was meant to show.
    • Done when: WHICH DOOR: the spec schema's refusal, so it lands wherever a list view is parsed through @objectstack/spec — defineView, defineStack, os validate / os build, and the metadata write door (PUT /api/v1/meta/view/:name, answering 422 INVALID_METADATA) — as one custom issue at chart for a view with no binding at all, or one per missing key at options.chart.dataset / options.chart.values for an incomplete bag (overlay only; the authoring doors refuse options by name). A stored sys_metadata view row is neither rewritten nor refused on read: measured, the read door serves it as stored with the same issue in its _diagnostics, and it is refused on its next save. Fix each chart view by declaring its binding, then open it: it plots the dataset. A chart view that already declares a complete chart block parses byte-identically to before, and every view of another type is untouched, a grid that only offers a chart in allowedVisualizations included. Census at the time of the change: the two chart list views in examples/app-showcase and the chart list views in the packages/lint fixtures all bind a dataset and a measure; the only spec test that parsed a block-less chart view was a type-acceptance pin, re-judged in the same change.
  • view-filter-rule-absent-value-refused — ui.ViewFilterRule with NO value on an operator that takes one — the value key omitted, or present and undefined, on equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before or after (an alias spelling of any of them included), on every carrier of ViewFilterRuleSchema → the value the rule compares against — value: "open" on equals, value: "2026-01-01" on after. A rule that meant "the field has no value" becomes one of the four operators that take none — is_empty / is_not_empty / is_null / is_not_null — which read their direction from their name and still parse with or without a value. A rule that was an unfinished row is deleted. The list operators (in / not_in) and the range operator (between) refused an absent value before this change and still do, in their own words
    • Why not automatic: The value key's own published description has declared, since the value was first shaped by its operator, that every operator outside the list, range and unary sets takes a scalar, and that only the unary operators ignore the key; the refinement implementing the coupling returned early on an absent value for every operator, so a rule with no value parsed green on all thirteen scalar operators. The query path refuses the same rule: both lowerings of a stored rule — the console's and the REST lookup-picker route's — emit it as the two-element [field, operator] node, which the filter-AST lowering reads as an undefined comparand and refuses with INVALID_FILTER / 400, measured for all thirteen operators. Nothing between storage and the query drops the rule, so one such rule failed every query that read its view, the view's other rules included. The first-party producer does not write the shape: the console filter builder drops a row whose operator takes a value and whose value is missing before it saves, and the drill-down save-as-view path checks each rule against this schema before persisting it (read at the pinned objectui commit). Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: there is no value to infer, and writing a value, switching to a unary operator and deleting the rule are three different predicates only the author can choose between. The read path does not re-validate stored rows (the reading the sibling entry view-filter-rule-scalar-operator-array-refused records), so a stored view keeps loading — and keeps failing its queries, as it did before this change; what changes is that RE-SAVING it is refused at the value path, naming the operator and the field. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep your authored views, pages and object-* blocks for a filter rule that has no value key and whose operator is none of the four unary operators, then decide per rule which of three things it meant: a comparison (write the value), a test for emptiness (switch to is_empty / is_not_empty / is_null / is_not_null), or an unfinished row (delete it). os validate reports each one by path with the operator and the field, so the sweep is mechanical rather than by eye. A view carrying one of these rules was refusing every query before this change, so re-check what it is supposed to show rather than assuming any earlier result set.
  • view-filter-rule-operator-input-canonical — ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the related-list, record-picker and object-* block filter doors) → the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, isNull to is_null, and the rest). A value that is not yet known to be an operator — read from storage, a URL or user input — is typed unknown and handed to ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the schema stays the judge
    • Why not automatic: The operator key is a z.preprocess over the alias fold, and zod types a preprocess's INPUT from its function's parameter. That parameter was unknown, so ViewFilterRule (a z.input) typed operator as unknown: a rule with operator: 42, or any string at all, compiled on every carrier and was refused only when the door parsed it. The typed input is now the canonical ViewFilterOperator, the vocabulary the alias table's own contract says new producers emit. The RUNTIME does not move: the door still folds every spelling it folded before to canonical and still refuses a non-string with the enum's own issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS producer that carries an alias keep parsing exactly as before, and os validate answers as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: what narrows is only what TypeScript source may write. The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ADR-0122.
    • Done when: Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a non-string, naming the canonical vocabulary. Rewrite each alias to the id VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the door already folded it to that id — and for a value typed string that is really unvalidated input, type it unknown and parse it rather than casting it. Stored views need no action: they load and parse as before.
  • view-filter-rule-scalar-operator-array-refused — ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on every carrier of ViewFilterRuleSchema → one scalar — a string, number, boolean or null. A rule written value: ["won"] on equals becomes value: "won"; a rule that really did mean membership of a list becomes operator: "in" with the array unchanged. The list operators (in / not_in) and the range operator (between) are untouched and still take their arrays. The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched too: they take their direction from the operator NAME and their value position is discarded, so whatever sits there still parses, array included. An omitted value is still an omitted value
    • Why not automatic: Closing the protocol half of the maintainer's ruling C-prime of 2026-09-20 on objectui's render-time filter converter — the protocol is the only refusal set, so a document it accepts never throws at render time — verbatim, untranslated: 「the differences are the protocol's to close」. The value key's own published description has declared this rule since the value was first shaped by its operator — 「every other operator takes a scalar」 — and the refinement that implements the coupling returned early for every operator that is neither a list operator nor between, so the entire scalar class was declared and, from then until this change, not judged. ⚠️ This REVERSES a reading recorded in the sibling entry view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an array as deliberately accepted because it 「lowers to a deep-equality comparand」. The backends a lowered view rule reaches at this release do not agree, so each is named rather than generalised. The SQL family REFUSES: the lowered node reaches driver-sql's bare field-value loop, which asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso's remote transport. driver-memory REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on an array in the implicit-equality position). driver-mongodb ANSWERS: its translateFilter passes the array through unchanged and the engine's shared comparand doors (normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the server applies MongoDB's equality rule for an array operand — a row matches when its stored array equals the value or holds the value as one of its elements, and a row storing the scalar does not match. That MongoDB reading is taken at the driver's compile face, at those engine doors and through mingo 7.2.4, which applies that rule; a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, driver-mongodb's translateFilter and mingo were each run on the lowered node beside a scalar and an $in control; MySQL and a live Turso server were NOT measured. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 conversions only. Coercing at load would be the platform guessing intent — an array of two on equals has no honest single value, and picking the first is a different predicate. The read path does not re-validate stored rows, so a stored view keeps loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ADR-0087 / ADR-0112.
    • Done when: Grep your authored views, pages and object-* blocks for a filter rule whose operator is none of in / not_in / between / the four unary operators and whose value is an array, then decide per rule which of the two things it meant: one value, or membership. os validate reports each one by path with the operator, the received shape and both corrected spellings, so the sweep is mechanical rather than by eye. Either way, re-check what the view is supposed to show rather than assuming the old result set was correct. A one-element array is the case to read closest: its two corrected spellings — value: "won" on equals, and operator: "in" with value: ["won"] — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows.
  • view-item-options-bag-refused — A top-level optionsbag on aview item RECORD ({ name, object, viewKind, config }) saved through the metadata write door (PUT /api/v1/meta/view/:name, the Studio / MCP save): options.kanban, options.calendar, options.timelineand every other key in it, on eitherviewKind. → The same per-kind blocks under the record's config — options.kanban becomes config.kanban, options.timeline becomes config.timeline — where each is judged by its kind's own block schema, and a key that block does not declare is re-spelled or deleted as its refusal says. A key config already sets wins; the bag's copy is deleted. The flattened list overlay (no config) keeps its legacy options bag, judged key by key, as before.
    • Why not automatic: The record member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped a top-level options bag from the parse without looking inside it, while the save stored the request body. The console reads a record's body from config on the object page but spreads the whole stored record on the interface page, so the same saved view rendered two ways. Now that the save stores the parsed body, the bag would instead vanish silently on the next save. The maintainer's ruling of 2026-09-30 refuses it by name with the prescription to write config.KIND; declaring it would have kept a second spelling of one block on a second member. No console write puts the bag on a record. Not convertible: which of two spellings of one block the author meant, where both are set, is the author's call.
    • Done when: Every stored view record carrying a top-level options saves again after its blocks move under config. A record that still carries the bag is refused 422 INVALID_METADATA on its next save, with an issue at options whose message prescribes config.KIND. Nothing is rewritten on read and nothing is refused on read: a record that fails is served exactly as stored until it is saved. Verify by re-saving each stored record that carries options (a GET then a PUT of the same body) and reading a 200.
  • view-item-owner-hidden-retired — view.owner / view.hidden on the view item record — the per-user owner and the switcher-hidden flag → (removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted; per-user view scoping is a parked direction, not a shipped mechanism.
    • Why not automatic: The D2 conversion view-item-owner-hidden-removed deletes both keys from every view item RECORD — in views (stack sources and stored rows) and in the assembled-manifest view item channel (package export, environment artifacts) — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on order, so hidden: true hid nothing and no scope ever read owner. The judgment is about exposure. A view an author marked as one user's, or hid from the switcher, has always been listed to every user who can read the object — its name, its columns, its filters and its sort. Whether anything in such a view was meant to stay private, and whether it should now be deleted rather than kept, is the author's call. A flattened view overlay's own owner and hidden are a separate family on a different door, with their own D2 conversion view-overlay-owner-hidden-removed and their own D3 entry view-overlay-owner-hidden-retired.
    • Done when: No view item record in views or in an assembled artifact carries owner or hidden; the parse refuses both by name, and an artifact assembled before the upgrade registers without a refusal over them. For every view that had carried either key, the author has confirmed that everything it shows may be listed to all readers of its object, or has deleted it. The view switcher lists the same views as before the upgrade.
  • view-overlay-judged-by-viewkind-arm — A flattened view overlay saved through the metadata write door (PUT /api/v1/meta/view/:name, the Studio / MCP save) whose viewKindnames one family while the body was judged by the other: a column-lessviewKind: "list"body, which only the form overlay member used to accept (its list keyssort, searchableFields, timeline, sharingand the rest stripped unread), and aviewKind: "form"body carrying listcolumns, which only the list overlay member used to accept. → Each overlay is judged by the member its viewKind names. A column-less list overlay is a patch on the view it shadows and carries list keys the list view schema accepts: a sort array of { field, order } (the bare string clause was retired in 17.5.0), no timeline.metaFields (the timeline block has no such key), an array searchableFields, and the list sharing block ({ type, lockedBy }), not the form public-link block. A column-less list overlay names no type; one that does is a full inline config and lists its columns. A form overlay's columns is its body-column count (an integer); a field list means the body is a list view (viewKind: "list") or belongs in sections: [{ fields }].
    • Why not automatic: Both overlay members shared one viewKind: list | form enum. The list member required columns, so it refused the column-less list patch the console writes on every toolbar save (the ruled patch-only storage shape, maintainer ruling: 「persistViewPatch 只存 patch,不存 merged base」); the union then tried the form member, which requires no list key and strips every one, and accepted it — so a retired sort string or a timeline.metaFields the list schema refuses by name was saved with success: true and stored as sent. Measured on origin/main @ 4df101c3 and again at ce70876e (after the options-bag door landed) through the real save. Ruled route C-prime: each member admits one viewKind, the list member judges a column-less patch (columns optional there only; the authoring list view keeps it required), and a column-less body that names a type stays refused at columns. Not convertible: whether a refused value was a typo or a stale capability is the author's call.
    • Done when: Every stored flattened view overlay saves again unchanged. A row that does not is refused 422 INVALID_METADATA on its next save, with the issue located at the refused key (sort, timeline, searchableFields, sharing, columns) and carrying that key's own message — for a column-less list overlay naming a type, the prescription at columns; for a field list on a form overlay, the body-column-count prescription at columns. Nothing is rewritten on read and nothing is refused on read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored flattened overlay (a GET then a PUT of the same body) and reading a 200.
  • view-overlay-options-bag-judged — The legacy optionsbag on a flattenedview overlay saved through the metadata write door (PUT /api/v1/meta/view/:name, the Studio / MCP save): options.kanban, options.calendar, options.gantt, options.gallery, options.timeline, options.chart, options.mapandoptions.tree on a list overlay, any other key in the bag, and the bag on a form overlay. → Each options.KIND block carrying only keys the top-level KIND block declares, with values that block accepts — or, preferred, the same keys moved to the top-level KIND block, which wins per key where both set one. A key the block does not declare is deleted or re-spelled to the declared key the refusal names (options.kanban.groupField becomes groupByField, options.calendar.dateField becomes startDateField); options.timeline.metaFields has no declared successor and is deleted. Any other key in the bag is deleted, and a form overlay carries no bag at all.
    • Why not automatic: The list overlay member re-opens its top level with a strip so the console's round-trip keys survive, and that strip dropped the options bag from the parse without looking inside it. The save stores the request body, not the parse output, and objectui's interface page forwards a stored view's options into the list renderer, which merges options.KIND under the top-level block — so a key the strict block refuses by name (timeline.metaFields) was saved and rendered when spelled options.timeline.metaFields. Measured on origin/main @ 8d1f7ab through the real save. Ruled direction A (the maintainer's ruling of 2026-09-24): judge each options.KIND with the kind's strict schema and refuse an out-of-contract key by name, as the direct spelling is; refusing the bag whole was ruled out because the legacy options.map path is live and pinned. Judged key by key, because the renderer reads the bag as a per-key underlay of the top-level block: a bag that carries only the keys the top-level block leaves to it is legal and stays accepted. Not convertible: whether a refused key was a typo of a declared one or a retired capability is the author's call.
    • Done when: Every stored view overlay carrying a top-level options saves again unchanged. A row that does not is refused 422 INVALID_METADATA on its next save, with an unrecognized_keys issue at options.KIND naming the key and carrying the same message the direct spelling gets at KIND — or at options for a key that is not a kind, or a form overlay's bag. Nothing is rewritten on read and nothing is refused on read: a row that fails is served exactly as stored until it is saved. Verify by re-saving each stored overlay that carries options (a GET then a PUT of the same body) and reading a 200.
  • view-overlay-owner-hidden-retired — view.owner / view.hidden on a flattened view overlay — the lean PUT /api/v1/meta/view/:name body with no config that the console saves for a view it personalizes → (removed — no per-user view scope and no switcher filter exists.) A view is listed to everyone who can read its object. A view that must not be listed is deleted, or no longer shipped from source; per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism.
    • Why not automatic: The D2 conversion view-overlay-owner-hidden-removed deletes both keys from every flattened overlay (a view body with no config and no container slot) — in views (stack sources, and every stored row, replayed on each read before it is served or badged) and in the assembled-manifest view item channel — and the delete is lossless: both switcher read paths filter on the view kind and object and sort on order, so an overlay saved with hidden: true hid nothing and no scope ever read owner. The judgment is about exposure, the same one the view item record's retirement leaves. A view someone hid or marked as one user's through its overlay has always been listed to every user who can read the object. The delete is lossless but does not always close the row: an overlay row that held nothing but its identity and these keys is left identity-only, a body the view door refuses, so that row needs its author (see the acceptance criteria). Measured writers in this repository and its sibling UI: zero (no source, example or skill, and objectui at its pinned commit and at main writes neither key on an overlay; the HotCRM app writes neither). NOT MEASURED: clients outside this repository, and production stored rows — the write door accepted and stored both until this release, and no deployment store is reachable from here.
    • Done when: No flattened view overlay you save carries owner or hidden: the write door refuses either with 422 INVALID_METADATA, the issue located at the key and the retirement prescription as its message. A stored overlay row that held either is stripped of it on every read, and what follows depends on what else the row holds. (1) A row with any other view key (a column state, a sort, a default flag, an order) is served and badged valid without the keys, a GET then a PUT of the whole row answers 200 (if it was otherwise valid), and os migrate meta --stored --apply rewrites it. (2) A hide-only row — nothing but its identity (name, object, viewKind, label) and owner / hidden, such as { object, viewKind, hidden: true } — is left with identity only, which the view door refuses ("only identity fields"): it is served badged invalid (it was badged valid before this release), a whole-row re-save or one that adds only identity answers 422 INVALID_METADATA, and --apply reports it failed and leaves it as stored. A write that adds a real view key, such as a toolbar toggle, saves. Resolve each such row: delete it (it never changed what anyone saw), or add the personalization setting its author meant and save that. For every view whose overlay had carried either key, its author has confirmed that the view may be listed to all readers of its object, or has deleted it. No switcher read path ever read either key, so which views the switcher lists does not change.
  • view-pagination-page-size-default-50 — ui.PaginationConfig.pageSize — an OMITTED page size on a view → nothing, to take the platform display page size of 50. To keep the old 25 rows per page on a view, write it: pagination: { pageSize: 25 }
    • Why not automatic: A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: the maintainer's ruling of 2026-09-24 set the platform display page size to 50, declared once in the protocol, and the declared default of PaginationConfigSchema.pageSize moved from 25 to 50. A pagination block that omits pageSize now parses to 50 — 50 rows per page on a paged view, and a fetch ceiling of 50 on a view with no pager (kanban, gallery, timeline). A view with no pagination block at all parses with none on either side; its page size reaches it through the renderer, which is ruled to read the spec default rather than keep its own number (an earlier ruling on the grid's page size, which the page-size ruling restated). Not losslessly convertible because the question is intent, not text: a mechanical pass that wrote pageSize: 25 into every silent view would preserve the old number and defeat the ruling, and one that wrote 50 would add nothing the default does not already do. Only the deployment knows which silent views were relying on 25. The accept set is unchanged — a positive integer — and every authored pageSize parses exactly as before.
    • Done when: An empty pagination configuration parses to a page size of 50, and a list view carrying pagination: {} parses to pagination.pageSize 50; an authored pagination: { pageSize: 25 } still parses to 25; pageSize: 0, a negative and a fraction are still refused. A view that must keep 25 rows per page declares pagination: { pageSize: 25 } and shows 25 rows on its first page.
  • visibility-strict-options-unexported — ``VISIBILITY_STRICT_OPTIONS(const) on@objectstack/spec/shared— the sharedstrictObject options of the visibility-carrying view/page shapes (ADR-0089 D3a) → (removed from the public surface — no replacement export. It was an internal option bag for this package's own schemas; the visibility contract it configures is unchanged and still published through the schemas that use it — FormFieldSchema, FormSectionSchema and the page component — together with normalizeVisibleWhen and VISIBILITY_ALIAS_KEYS, which stay exported.)
    • Why not automatic: ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its type, StrictObjectOptions, is deliberately unpublished, so no consumer could annotate it, spread it into a typed option bag or name it in a parameter — a published value with no usable contract and zero measured pull outside this package. Publishing the type instead was weighed and not adopted: no consumer ever asked for it, and it would turn the strict-object template's internals into public API.
    • Done when: No code imports VISIBILITY_STRICT_OPTIONS from @objectstack/spec, @objectstack/spec/shared or any other entry (TS2305 after upgrade). Every visibility-carrying shape parses and refuses exactly as before — the options object is unchanged, only where it is exported from moved. No authored metadata document ever carried it, so os migrate meta has nothing to visit.
  • wait-node-event-config-required — The waitEventConfigblock of everytype: 'wait'flow node, and theboundaryConfigblock of everytype: 'boundary_event'node — the BLOCK, not a key inside it.eventTypehas been required INSIDE each block since protocol 17, so the contract already refusedwaitEventConfig: {}; what it also accepted was the block missing entirely, which is the state a freshly created node is in. Two documents, two verdicts, and the accepted one was the silent one. Also narrowed one level down: under eventType: 'timer', timerDurationis now required and may not be blank. ⚠️ That second narrowing sits on the BLOCK and is NOT gated ontype: 'wait', so it reaches any node type that carries a waitEventConfigat all — astartnode spelledwaitEventConfig: { eventType: 'timer' } parsed before and is refused now. Inert in practice, because no executor but the wait one reads the block, but a stack that spells it elsewhere must be edited too, so scan for the KEY and not only for the node type. → Declare what resumes the node, on the node: waitEventConfig: { eventType: 'timer', timerDuration: 'PT1H' } for a delay — QUOTE a bare number, the key is a string and a numeric string is read as milliseconds, so '60000' is the same 60s wait as 'PT1M' — or { eventType: 'signal' | 'webhook' | 'manual' | 'condition', signalName: '<event>' } when an external producer resumes the run. For boundary_event, boundaryConfig: { attachedToNodeId: '<host node>', eventType: 'error' | 'timer' | 'signal' | 'cancel' }. ⛔ There is deliberately NO default for either eventType: a required key has no "unset behaves as", and an indefinite park — if one is ever wanted — is its own declared eventType, never the absence of configuration. ⚠️ boundary_event has no executor in the runtime at all (a flow reaching one fails with NO_EXECUTOR), so a stored boundary node is an authoring-surface repair: the native construct for error handling is a try_catch region (ADR-0031).
    • Why not automatic: Maintainer ruling of 2026-09-13, the clause of the reply that covers this item, verbatim and untranslated: 「其他同意」 — carrying the presented option: the protocol is the source of truth; a designer never invents a default the protocol does not apply; a default the protocol should have is declared by the protocol; a required key has no "unset behaves as". ⛔ NOT losslessly convertible, and the reason is that the missing value is an INTENT no artifact records: a block-less wait node does not say whether its author meant a delay (and for how long) or a named signal (and which one), and a transform that picked one would be inventing the very default this ruling forbids. What the old runtime picked was 'timer' with no duration, which is not a wait at all: measured through a real engine.execute() run, such a node answered { success: true, suspend: true }, scheduled no wake-up job THOUGH A JOB SERVICE WAS ANSWERING, persisted no waitUntil for a later boot's re-arm pass, and emitted not one log line at any level — the run parked forever and reported success. So the conversion layer (D2) cannot hide this break and the tombstone channel cannot carry it either (nothing was renamed or retired; a key that was optional became required), which leaves D3: a structured TODO naming each node that must be edited. The alternative considered and NOT taken was to warn and keep parsing — a warning on the authoring path an AI agent drives is read by nobody, and the agent reports "done" over a flow that hangs.
    • Done when: Every type: 'wait' node in the stack — at the top level AND inside every ADR-0031 region body — carries a waitEventConfig with an eventType, and every one whose eventType is 'timer' carries a non-blank timerDuration; every type: 'boundary_event' node carries a boundaryConfig with an attachedToNodeId and an eventType. FlowSchema.parse (and therefore registerFlow, os validate and a Studio publish) accepts the stack: a node still missing its block is refused with the key named at nodes[i].waitEventConfig / nodes[i].boundaryConfig and the remedy in the message. ⚠️ A region body is checked through the REGION contract rather than the flow parse — parseFlowNodeRegions leaves a refused region raw — so a nested node is named by LoopConfigSchema / ParallelConfigSchema / TryCatchConfigSchema at body.nodes[i].waitEventConfig, and at run time by the container node's own execute-time config parse; check the nested ones by parsing the container config, not only by parsing the flow. Behaviour to re-check after editing, because the fix CHANGES IT deliberately: a run that used to park forever on such a node now either waits the duration you declared or waits for the signal you named — anything that resumed those runs by hand (an operator calling resume(runId), a nightly sweep) has less to do, and anything that COUNTED on the park is now on a timer.
  • websocket-durations-unit-in-key — the four WebSocket configuration durations whose name carried no unit: WebSocketConfig.reconnectInterval, WebSocketConfig.pingInterval, WebSocketConfig.timeout and WebSocketServerConfig.heartbeatInterval (api/websocket.zod.ts) → reconnectIntervalMs, pingIntervalMs, timeoutMs and heartbeatIntervalMs — rename each key; every value is unchanged, and so is every default (1000, 30000, 5000, 30000)
    • Why not automatic: Maintainer ruling B (2026-09-02, extended on 2026-09-05 to runtime-emitted durations): the unit of a duration-shaped z.number() lives in the key NAME or in a unit-carrying value, never only in the describe prose, and no existing offender is grandfathered. What makes this shape worth one entry rather than four is the neighbour: on both configs a bare duration sits directly beside a bare COUNT — maxReconnectAttempts on the client, reconnectAttempts on the server — so reconnectInterval: 5 and maxReconnectAttempts: 5 read as the same kind of number and are not. Suffixing the durations separates the two families at the authoring site; the counts keep their names, because a count has no unit to carry. All four are retiredKey() tombstones (neither shape is strict, so a bare deletion would strip in silence). Why a semantic entry and not a D2 conversion: a WebSocketConfig is a client CONNECTION argument and a WebSocketServerConfig is a server CONSTRUCTION argument — neither is a stack collection member and neither is ever stored as a sys_metadata row, so the conversion chain has no seam that would see one. The same disposition the epoch-instant renames on this file took (epoch-instant-keys-renamed), and what ruling B prescribes for a key that is not authorable metadata. ADR-0087.
    • Done when: Every WebSocketConfigSchema.parse(…) / WebSocketServerConfigSchema.parse(…) site and every literal handed to a WebSocket client or server spells the suffixed keys; authoring any old spelling fails to compile (input type never) and fails to parse with the rename prescription. Behaviour is unchanged in every case: a client configured with reconnectIntervalMs: 2000 retries after two seconds exactly as reconnectInterval: 2000 did, and a config that omits the keys still gets the same defaults. The positive-integer bounds ride along with the renamed keys, so a zero or negative interval is still refused.

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