From f1325e9a50925aa991f22054c83e8e41eecadb46 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:00:56 +0000 Subject: [PATCH 1/8] fix(runtime): the /i18n dispatcher domain refuses an anonymous caller first handleI18nRequest opens with the shared anonymous-deny floor (shouldDenyAnonymous, ADR-0056 D2) ahead of the provider probe and every route, in the hoisted form the analytics and security domains use. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../src/domains/i18n-anonymous-deny.test.ts | 278 ++++++++++++++++++ packages/runtime/src/domains/i18n.ts | 48 ++- 2 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/domains/i18n-anonymous-deny.test.ts diff --git a/packages/runtime/src/domains/i18n-anonymous-deny.test.ts b/packages/runtime/src/domains/i18n-anonymous-deny.test.ts new file mode 100644 index 00000000000..22b30fd22a8 --- /dev/null +++ b/packages/runtime/src/domains/i18n-anonymous-deny.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22432 — the `/i18n` dispatcher domain stands on the anonymous-deny floor + * (ADR-0056 D2), and the floor is the handler's FIRST statement. + * + * ## What is pinned, per face + * + * `handleI18nRequest` is the one handler body behind every `/i18n` face — the + * locale list, the translation bundle and the field labels, each in both the + * path and the query spelling the body accepts. For each face, an anonymous + * caller (both shapes the dispatcher produces: an unresolved context, and the + * guest envelope `assembleExecutionContextOrGuest` builds for an + * unauthenticated request) is answered the dispatcher-wrapper + * `401 UNAUTHENTICATED`, and: + * + * - the i18n service is NEVER consulted — neither the slot lookup nor any + * method on the service. The gate decides before anything about the + * deployment is read, and nothing of the bundle is served; + * - a malformed input (no locale) is STILL a 401, never the 400 the route + * would answer: an anonymous caller does not get to learn what a route + * reads; + * - an empty slot is STILL a 401, never the 501 the domain answers when no + * provider is mounted: an anonymous caller does not get to learn whether + * the deployment carries one. + * + * ## What must NOT change, pinned just as hard + * + * A gate that refused everyone would satisfy every case above and still be a + * regression, so the signed-in half is the control: a member is served by the + * real handler body exactly as before (the locale list, the bundle, the + * labels), a member's malformed input still gets the route's 400, an empty + * slot still answers a member the 501, and an internal SYSTEM context still + * passes. Those are what show the 401s are the floor's answer and not a + * broken door. The CORS preflight (`OPTIONS`) stays outside the floor, as it + * is on every door that shares the predicate. + * + * ⛔ The envelope builder is the REAL one the dispatcher wires in + * (`apiErrorResponse`), so `error.code` is read where the wire carries it — + * a stub that dropped the third argument would make every code assertion + * here vacuous. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; + +import { handleI18nRequest } from './i18n.js'; +import { apiErrorResponse } from '../error-envelope.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; + +// ── contexts ──────────────────────────────────────────────────────────────── + +const anonUnresolved = () => ({ request: { headers: {} } }) as unknown as HttpProtocolContext; +/** What the dispatcher builds for an unauthenticated request: a guest, no user id. */ +const guestEnvelope = () => ({ + request: { headers: {} }, + executionContext: { + isSystem: false, principalKind: 'guest', positions: ['guest'], permissions: [], systemPermissions: [], + }, +}) as unknown as HttpProtocolContext; +const MEMBER_EC = { userId: 'usr_member', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; +const member = () => ({ request: { headers: {} }, executionContext: { ...MEMBER_EC } }) as unknown as HttpProtocolContext; +const system = () => ({ request: { headers: {} }, executionContext: { isSystem: true } }) as unknown as HttpProtocolContext; + +const ANONYMOUS_CONTEXTS: ReadonlyArray HttpProtocolContext]> = [ + ['an unresolved context', anonUnresolved], + ['the guest envelope', guestEnvelope], +]; + +// ── the bundle the fake provider serves ───────────────────────────────────── + +const BUNDLE = { + objects: { lead: { label: 'Lead (fr)', fields: { company: { label: 'Société' } } } }, +}; + +// ── the faces ─────────────────────────────────────────────────────────────── + +interface Face { + readonly face: string; + readonly subPath: string; + readonly query?: Record; + /** The same face with its locale left out — the route's own 400 for a member. */ + readonly malformed?: { readonly subPath: string; readonly query?: Record }; + /** What a signed-in caller is served in `data`. */ + readonly served: Record; + /** The service method the face reads from. */ + readonly reads: 'getLocales' | 'getTranslations'; +} + +const FACES: readonly Face[] = [ + { + face: 'the locale list', + subPath: '/locales', + served: { locales: [{ code: 'en', label: 'en', isDefault: true }, { code: 'fr', label: 'fr', isDefault: false }] }, + reads: 'getLocales', + }, + { + face: 'the translation bundle (path spelling)', + subPath: '/translations/fr', + malformed: { subPath: '/translations' }, + served: { locale: 'fr', translations: BUNDLE }, + reads: 'getTranslations', + }, + { + face: 'the translation bundle (query spelling)', + subPath: '/translations', query: { locale: 'fr' }, + malformed: { subPath: '/translations', query: {} }, + served: { locale: 'fr', translations: BUNDLE }, + reads: 'getTranslations', + }, + { + face: 'the field labels (path spelling)', + subPath: '/labels/lead/fr', + malformed: { subPath: '/labels/lead' }, + served: { object: 'lead', locale: 'fr', labels: { company: 'Société' } }, + reads: 'getTranslations', + }, + { + face: 'the field labels (query spelling)', + subPath: '/labels/lead', query: { locale: 'fr' }, + malformed: { subPath: '/labels/lead', query: {} }, + served: { object: 'lead', locale: 'fr', labels: { company: 'Société' } }, + reads: 'getTranslations', + }, +]; + +// ── deps ──────────────────────────────────────────────────────────────────── + +function makeService() { + return { + getLocales: vi.fn(() => ['en', 'fr']), + getDefaultLocale: vi.fn(() => 'en'), + getTranslations: vi.fn((locale: string) => (locale === 'fr' ? BUNDLE : {})), + }; +} + +function makeDeps(service: ReturnType | undefined) { + const getService = vi.fn(async () => service); + const resolveService = vi.fn(async () => service); + const deps = { + getService, + resolveService, + success: (data: any) => ({ status: 200, body: { success: true, data } }), + error: (message: string, httpStatus = 500, details?: any) => + apiErrorResponse({ message, httpStatus, details }), + } as unknown as DomainHandlerDeps; + return { deps, getService, resolveService }; +} + +function call( + deps: DomainHandlerDeps, + input: { subPath: string; query?: Record }, + context: HttpProtocolContext, + method = 'GET', +) { + return handleI18nRequest(deps, input.subPath, method, input.query ?? {}, context); +} + +/** The ADR-0112 refusal envelope, dispatcher-wrapper family: status AND code, never one alone. */ +function expectAnonymousDenied(result: any) { + expect(result.handled).toBe(true); + expect(result.response.status).toBe(ANONYMOUS_DENY_STATUS); + expect(result.response.status).toBe(401); + expect(result.response.body.success).toBe(false); + expect(result.response.body.error.code).toBe(ANONYMOUS_DENY_CODE); + expect(result.response.body.error.code).toBe('UNAUTHENTICATED'); + expect(result.response.body.error.httpStatus).toBe(401); + expect(result.response.body.error.message).toBe(ANONYMOUS_DENY_MESSAGE); + // Nothing of the bundle is served: no `data`, and no bundle key anywhere in the body. + expect(result.response.body.data).toBeUndefined(); + const wire = JSON.stringify(result.response.body); + for (const leaked of ['translations', 'locales', 'labels', 'Société', 'Lead (fr)']) { + expect(wire, `the refusal must not carry ${leaked}`).not.toContain(leaked); + } +} + +function expectNeverConsulted( + service: ReturnType, + seams: { getService: ReturnType; resolveService: ReturnType }, +) { + expect(seams.getService).not.toHaveBeenCalled(); + expect(seams.resolveService).not.toHaveBeenCalled(); + expect(service.getLocales).not.toHaveBeenCalled(); + expect(service.getDefaultLocale).not.toHaveBeenCalled(); + expect(service.getTranslations).not.toHaveBeenCalled(); +} + +// ── A: the floor, per face ────────────────────────────────────────────────── + +describe.each(FACES)('#22432 A — $face refuses an anonymous caller before anything else runs', (face) => { + it.each(ANONYMOUS_CONTEXTS)('%s gets 401 UNAUTHENTICATED and the service is never consulted', async (_label, ctx) => { + const service = makeService(); + const { deps, getService, resolveService } = makeDeps(service); + expectAnonymousDenied(await call(deps, face, ctx())); + expectNeverConsulted(service, { getService, resolveService }); + }); + + it.each(ANONYMOUS_CONTEXTS)('%s with the locale left out still gets 401, never the route\'s 400', async (_label, ctx) => { + const service = makeService(); + const { deps, getService, resolveService } = makeDeps(service); + expectAnonymousDenied(await call(deps, face.malformed ?? face, ctx())); + expectNeverConsulted(service, { getService, resolveService }); + }); + + it.each(ANONYMOUS_CONTEXTS)('%s against an empty slot still gets 401, never the provider 501', async (_label, ctx) => { + const { deps, getService } = makeDeps(undefined); + expectAnonymousDenied(await call(deps, face, ctx())); + expect(getService).not.toHaveBeenCalled(); + }); +}); + +describe('#22432 A — the floor is domain-wide, not per route', () => { + it.each(ANONYMOUS_CONTEXTS)('%s gets 401 on a sub-path no route serves, and on a non-GET verb', async (_label, ctx) => { + // A face added later converges on the same body, so it arrives behind + // the floor: an unknown sub-path and a write verb are refused the same + // way, before the route table is read. + for (const [subPath, method] of [['/no-such-face', 'GET'], ['/translations/fr', 'POST']] as const) { + const service = makeService(); + const { deps, getService, resolveService } = makeDeps(service); + expectAnonymousDenied(await call(deps, { subPath }, ctx(), method)); + expectNeverConsulted(service, { getService, resolveService }); + } + }); + + it('a CORS preflight stays outside the floor, as on every door that shares the predicate', async () => { + const service = makeService(); + const { deps } = makeDeps(service); + // `OPTIONS` is not a route here, so the body answers `handled: false` + // and the transport owns the preflight — the 401 would break CORS. + expect(await call(deps, { subPath: '/locales' }, anonUnresolved(), 'OPTIONS')).toEqual({ handled: false }); + }); +}); + +// ── B: the signed-in control, per face ────────────────────────────────────── + +describe.each(FACES)('#22432 B — $face still serves a signed-in caller exactly as before', (face) => { + it('a member is served 200 by the provider', async () => { + const service = makeService(); + const { deps, getService } = makeDeps(service); + const result: any = await call(deps, face, member()); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(200); + expect(result.response.body).toEqual({ success: true, data: face.served }); + expect(getService).toHaveBeenCalledTimes(1); + expect(service[face.reads]).toHaveBeenCalled(); + }); + + it('an internal SYSTEM context passes the floor too', async () => { + const service = makeService(); + const { deps } = makeDeps(service); + const result: any = await call(deps, face, system()); + expect(result.response.status).toBe(200); + expect(result.response.body.data).toEqual(face.served); + }); + + it('an empty slot still answers a member the provider 501', async () => { + const { deps, getService } = makeDeps(undefined); + const result: any = await call(deps, face, member()); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(501); + expect(getService).toHaveBeenCalledTimes(1); + }); +}); + +describe.each(FACES.filter((f) => f.malformed))('#22432 B — $face still answers a member\'s missing locale with the route\'s 400', (face) => { + it('a member who leaves the locale out gets 400, and the bundle is not read', async () => { + const service = makeService(); + const { deps } = makeDeps(service); + const result: any = await call(deps, face.malformed!, member()); + expect(result.handled).toBe(true); + expect(result.response.status).toBe(400); + expect(result.response.body.success).toBe(false); + expect(service.getTranslations).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/i18n.ts b/packages/runtime/src/domains/i18n.ts index 469c0eac200..d111be27d4d 100644 --- a/packages/runtime/src/domains/i18n.ts +++ b/packages/runtime/src/domains/i18n.ts @@ -14,9 +14,20 @@ * GET /translations?locale=xx → getTranslations (locale from query) * GET /labels/:object/:locale → getFieldLabels (both from path) * GET /labels/:object?locale=xx → getFieldLabels (locale from query) + * + * Every one of those answers is for an authenticated caller: an anonymous one + * is refused before the slot is consulted (ADR-0056 D2, see the first + * statement of {@link handleI18nRequest}). The translation bundle carries the + * labels of the application's objects, fields, apps and pages, so it stands on + * the same floor as the metadata it translates. A client renders its sign-in + * page from its own built-in strings and reads this domain once it holds a + * session. */ -import { resolveLocale } from '@objectstack/core'; +import { + resolveLocale, + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import { CoreServiceName, resolveObjectFieldLabels, toLocaleDescriptors } from '@objectstack/spec/system'; import { isServiceServeable } from '../service-serveable.js'; import type { TranslationData } from '@objectstack/spec/system'; @@ -37,9 +48,40 @@ export async function handleI18nRequest( path: string, method: string, query: any, - _context: HttpProtocolContext, + context: HttpProtocolContext, ): Promise { - const i18nService = await deps.getService(_context, CoreServiceName.enum.i18n); + // [#22432] ANONYMOUS BASELINE (ADR-0056 D2) — the FIRST statement, ahead + // of the service-availability probe and every route below, in the hoisted + // form `domains/analytics.ts` and `domains/security.ts` use. The bundle + // this domain serves names every object, field, app, page and dashboard + // the application declares, and the metadata read of the same object + // already answers an anonymous caller 401; ADR-0138 D2's door classes and + // the control-plane allowlist name no translation door. + // + // Why here and nowhere else: every `/i18n` face (locales, translations, + // field labels, both spellings of each) converges on this ONE handler + // body, whichever transport delivered it, so a single domain-wide gate + // covers them all and a face added later cannot arrive ungated. ⛔ No + // second gate at the dispatcher mount. + // + // Why ahead of the probe: an anonymous caller must not learn from a 501 + // versus a 401 whether this deployment carries an i18n provider, nor from + // a 400 which parameters a route reads. + // + // The dispatcher hands an unauthenticated request to this handler as the + // guest envelope (`assembleExecutionContextOrGuest`), which carries no + // `userId`; an unresolved context carries none either. Both are denied. + // A client renders its sign-in page from its own built-in strings and + // reads this domain once it holds a session. + const ec = context?.executionContext; + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + + const i18nService = await deps.getService(context, CoreServiceName.enum.i18n); // [#4058] An empty slot and a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12) are the same amount of i18n. Both // in-memory providers of this slot really translate, so both declare From 8bad43847469a0640369a0e392968acb5ab265c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:07:14 +0000 Subject: [PATCH 2/8] test(dogfood): classify the /i18n dispatcher domain on the authz matrix The anonymous-deny-i18n row covers the domain's gate pin and its dispatcher-domain key, which leaves the shrink-only population baseline (31 to 30). The probe census and the matrix header are re-derived on the merged ref, the booted showcase proof drives every mounted face, the system-context page anchors the new elevation read, and the changeset declares the narrowing. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/22432-i18n-anonymous-deny.md | 25 +++++ content/docs/permissions/system-context.mdx | 20 ++-- .../dogfood/test/authz-conformance.matrix.ts | 28 ++++-- .../qa/dogfood/test/authz-conformance.test.ts | 12 +++ .../test/authz-ledger-population.baseline.ts | 10 +- .../test/authz-probe-blind-spot.census.ts | 53 +++++++++-- .../test/authz-probe-blind-spot.test.ts | 13 +-- ...se-anonymous-deny-surfaces.dogfood.test.ts | 93 ++++++++++++++++++- .../src/domains/i18n-anonymous-deny.test.ts | 4 +- 9 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 .changeset/22432-i18n-anonymous-deny.md diff --git a/.changeset/22432-i18n-anonymous-deny.md b/.changeset/22432-i18n-anonymous-deny.md new file mode 100644 index 00000000000..623d6179050 --- /dev/null +++ b/.changeset/22432-i18n-anonymous-deny.md @@ -0,0 +1,25 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the `/i18n` dispatcher domain refuses a caller without a session with `401 UNAUTHENTICATED`, like every other dispatcher domain + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The `/i18n` dispatcher domain serves the application's translations: the locale list, the translation bundle and the field labels. The bundle carries the labels of every object, field, app and page the application declares. Until now the domain served a caller with no session, while the metadata read of the same objects refused one. ADR-0056 D2 denies anonymous callers by default. + +**What changed.** The domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`), the same floor the `/meta`, `/actions`, `/automation`, `/packages` and `/analytics` domains stand on. It is the handler's first statement: + +- every face of the domain answers a caller without a session `401` with code `UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`), and serves nothing of the bundle; +- it runs before the i18n provider is looked up, so the answer is `401` whether or not a provider is installed, never the `501` an empty slot answers; +- it runs before a face reads its parameters, so a request that leaves out its locale is `401`, never the `400` that face answers. + +**What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same bundle, the same `400` for a missing locale, the same `501` when no provider is installed. A CORS preflight is unchanged. + +**The Console.** The Console this release pins renders its sign-in page from its built-in language packs and loads the application's translations after sign-in, with the signed-in session. A signed-in Console user sees the application's labels as before. + +**If you read `/i18n` from your own client,** send the signed-in user's session or bearer token, or an API key, with the request. Render anything shown before sign-in from strings your client ships, and load the application's translations once the user has signed in. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b67fef25a87..703d05b4a4d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **121 +because the flag is not one concept: it is a single boolean read at **122 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 121 sites**. +The largest single consumer — **17 of the 122 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -176,7 +176,7 @@ The largest single consumer — **17 of the 121 sites**. | 48 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` | | 49 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/rest-server.ts#metaSaveVerdict` | | 50 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 49's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | -| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | +| 51 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` | | 52 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` | | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | @@ -285,7 +285,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 121 read sites +- **Shipped semantics.** `isSystem` is a published contract with 122 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -359,16 +359,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 28 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 127 | ✅ | +| — parsed as a property **read** | 128 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **121** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 118 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **122** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 119 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | -| Files containing at least one elevation read | 57 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 103 | ✅ | +| Files containing at least one elevation read | 58 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 104 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were @@ -432,7 +432,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **57** +cannot say WHICH read inside a function it means, and **8** of the **58** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index d7b82202c88..18af5cecd02 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -10,7 +10,7 @@ // file exists and that the row ↔ proof pairing is MUTUAL (#7976 below), AND // ratchets completeness at ROUTE-FAMILY AND DISPATCHER-DOMAIN granularity over // the two route ledgers, plus a curated table of named gates and transport -// tripwires (`discover()`: 19 probes over 14 named source files) — a new REST +// tripwires (`discover()`: 20 probes over 15 named source files) — a new REST // route FAMILY or dispatcher DOMAIN is UNCLASSIFIED, a deleted named guard is // STALE, and either breaks CI. // @@ -67,8 +67,8 @@ // The ledgers supply the POPULATION; the classification stays a reviewed row // here. // -// Of the 39 ledger keys, 7 are classified by rows below that already pinned -// the same surface through a probe; the other 32 are enumerated one by one, +// Of the 39 ledger keys, 9 are classified by rows below (re-derived when the +// `/i18n` domain left the baseline, #22432); the other 30 are enumerated one by one, // dated, and pinned SHRINK-ONLY in `authz-ledger-population.baseline.ts`, // whose MAX note dates every step down from the 34 of 40 it held on the // 2026-08-31 adoption day. That list can only get shorter: growth, staleness, @@ -76,7 +76,7 @@ // 2026-08-31 those surfaces minted no key at all, so nothing about them was // visible in either direction. // -// Each of the 19 probes DECLARES its instrument kind +// Each of the 20 probes DECLARES its instrument kind // (ROUTE_ENUMERATION / GATE_PIN / TRIPWIRE — see the companion test), and a // non-tripwire probe that mints ZERO keys fails as a DEAD PROBE. That closes a // blind-spot mechanism neither UNCLASSIFIED nor STALE can reach: both are @@ -98,14 +98,14 @@ // [commit 2ce1eb41b] That completeness is over ROUTES, not over primitives: a primitive // enforced by a predicate inside an existing resolver adds no entry point, so // it can be neither UNCLASSIFIED nor STALE. Measured against the rows below: -// 44 of 52 carry no `covers` key at all (8 rows, 17 keys, every one an -// HTTP/transport pin), and 38 of the file's 45 `enforced` rows are exactly +// 44 of 54 carry no `covers` key at all (10 rows, 20 keys, every one an +// HTTP/transport pin), and 38 of the file's 47 `enforced` rows are exactly // that in-resolver shape — the ADR-0049/#8613 `active` rows and the ADR-0091 // grant-validity-window row among them (see their own blocks further down) // are the normal case, not an exception. Of the -// 17 `covers` keys that DO exist, 6 are GATE pins tied to the enforcement call +// 20 `covers` keys that DO exist, 7 are GATE pins tied to the enforcement call // itself, not merely a function name — delete `shouldDenyAnonymous` from -// `/actions`, `/automation`, `/packages` or `/analytics`, or drop the MCP +// `/actions`, `/automation`, `/packages`, `/analytics` or `/i18n`, or drop the MCP // context-threading / stdio principal binding, and the pinned key vanishes from source, its row // goes STALE, and CI catches the regression. That anti-regression property is // real and is what this file mechanically delivers. Outside the curated @@ -294,6 +294,18 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // family key needs. covers: ['rest-family:rest-route-ledger.ts:openapi'], note: 'Measured on a booted showcase before the change: an unauthenticated caller was served the document and the viewer page with 200, just as a signed-in caller was, while the record doors on the same boot answered it 401 — ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names these endpoints. The cited proof drives both endpoints anonymously (401, the REST flat envelope, nothing of either served) and with a signed-in member (200, the document / the viewer page) on one boot. The environment-scoped twin mounts only under project scoping, which the showcase does not enable; it is the same handler closure, pinned per base in rest/src/rest-api-description-anonymous-deny.test.ts beside the gated-session 403 and the permission-store-outage 503. The viewer page fetches the document from the browser, and that request carries the browser\'s session (measured with the real viewer in a browser), so a signed-in browser is served on both endpoints; a deployment that publishes its API description to readers without an account publishes a static copy of the document instead.' }, + // #22432 — the `/i18n` dispatcher domain, under the ruling recorded on #22146 + // (the domain gains the domain-level anonymous refusal every other dispatcher + // domain has, with an objectui companion for the Console's sign-in page). + // Every face converges on ONE handler body, `handleI18nRequest`, so one + // domain-wide floor there covers them all — the `/analytics` shape above. + { id: 'anonymous-deny-i18n', summary: 'anonymous-deny on the translation dispatcher surface', state: 'enforced', + enforcement: 'runtime/domains/i18n.ts handleI18nRequest — shouldDenyAnonymous DOMAIN-WIDE as the handler\'s FIRST statement, ahead of the provider probe (so a 401-vs-501 difference cannot fingerprint whether the deployment carries an i18n provider) and ahead of every face (so an anonymous request that leaves out its locale is a 401, never the 400 describing what a face reads); every face answers the dispatcher-wrapper 401 UNAUTHENTICATED and serves nothing of the bundle, and a signed-in caller is served as before', + proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', + // The DISPATCHER domain at ledger granularity, beside the gate pin — the + // pairing the `/packages` and `/analytics` rows make. + covers: ['i18n:domains/i18n.ts:anonymous-gate', 'dispatcher-domain:route-ledger.ts:/i18n'], + note: 'Ungated, an unauthenticated caller was served the application\'s translation bundle (the labels of its objects, fields, apps and pages) while the metadata read of the same objects answered it 401 on the same boot; ADR-0056 D2 is default-deny and no ADR-0138 D2 door class names this domain. Gating the DOMAIN rather than each face keeps a newly added face from arriving ungated. The Console renders its sign-in page from its built-in packs and reads this domain once signed in, so the console pin moved past that change in the same landing. The cited proof drives every mounted face anonymously (401, the dispatcher-wrapper envelope, nothing of the bundle served) and with a signed-in member (200, served) on one boot. The per-face unit pins (anonymous 401 with the provider never consulted, a missing locale and an empty slot still 401, signed-in control unchanged) live in runtime/domains/i18n-anonymous-deny.test.ts.' }, // ── #2992 / ADR-0096 D4 — latent execution surfaces (pre-wiring identity // admission). Neither surface is reachable by a client today; these rows diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index e874e69f8e9..79a460345b0 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -250,6 +250,16 @@ const PROBES: readonly Probe[] = [ re: /shouldDenyAnonymous\s*\(/g, key: () => 'analytics:domains/analytics.ts:anonymous-gate', }, + // #22432 — the /i18n domain gate. Same GATE-pin shape: the key exists only + // while `handleI18nRequest` still consults `shouldDenyAnonymous`. Delete the + // domain floor and the key vanishes → the covering `anonymous-deny-i18n` row + // goes STALE → red CI. + { + kind: 'GATE_PIN', + file: 'packages/runtime/src/domains/i18n.ts', + re: /shouldDenyAnonymous\s*\(/g, + key: () => 'i18n:domains/i18n.ts:anonymous-gate', + }, // ── a probe whose POPULATION WAS DELETED, re-declared for what it is ──── // @@ -973,6 +983,8 @@ describe('the ledger-sourced population and its baseline bite', () => { 'dispatcher-domain:route-ledger.ts:/actions', 'dispatcher-domain:route-ledger.ts:/analytics', 'dispatcher-domain:route-ledger.ts:/automation', + // [#22432] classified by `anonymous-deny-i18n`. + 'dispatcher-domain:route-ledger.ts:/i18n', 'dispatcher-domain:route-ledger.ts:/mcp', 'dispatcher-domain:route-ledger.ts:/meta', 'dispatcher-domain:route-ledger.ts:/packages', diff --git a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts index f1caf570ad0..aa731b654de 100644 --- a/packages/qa/dogfood/test/authz-ledger-population.baseline.ts +++ b/packages/qa/dogfood/test/authz-ledger-population.baseline.ts @@ -96,7 +96,8 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ // ── dispatcher domains (`packages/runtime/src/route-ledger.ts`) ───────── // Absent because classified: `/meta`, `/actions`, `/automation`, - // `/packages`, `/mcp`, `/analytics`. The other 15 domains are here. + // `/packages`, `/mcp`, `/analytics`, `/i18n` (#22432). The other 14 domains + // are here. 'dispatcher-domain:route-ledger.ts:/.well-known/objectstack', 'dispatcher-domain:route-ledger.ts:/ai', 'dispatcher-domain:route-ledger.ts:/apps', @@ -104,7 +105,6 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ 'dispatcher-domain:route-ledger.ts:/data', 'dispatcher-domain:route-ledger.ts:/discovery', 'dispatcher-domain:route-ledger.ts:/health', - 'dispatcher-domain:route-ledger.ts:/i18n', 'dispatcher-domain:route-ledger.ts:/keys', // ⚠️ Separate from `/mcp`, deliberately. The `/mcp` key is classified by // `mcp-http-identity`, whose enforcement site is `handleMcp`; `/mcp/skill` @@ -129,6 +129,8 @@ export const LEDGER_POPULATION_BASELINE: readonly string[] = [ * (#21061): `dispatcher-domain:route-ledger.ts:/analytics` left classified, by * the `anonymous-deny-analytics` row and its domain gate pin. 31 at 2026-10-09 * (#22430): `rest-family:rest-route-ledger.ts:openapi` left classified, by the - * `anonymous-deny-api-description` row and its booted-showcase proof. + * `anonymous-deny-api-description` row and its booted-showcase proof. 30 at + * 2026-10-09 (#22432): `dispatcher-domain:route-ledger.ts:/i18n` left + * classified, by the `anonymous-deny-i18n` row and its domain gate pin. */ -export const LEDGER_POPULATION_BASELINE_MAX = 31; +export const LEDGER_POPULATION_BASELINE_MAX = 30; diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index bb35642f2a7..ed1e8a4ae07 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -7,7 +7,7 @@ // "ratchets completeness over a CURATED table of HTTP/transport entry points" // and that "a new ungated route there is UNCLASSIFIED ... and breaks CI". That // promise is true only for the entry points a probe can actually mint a key -// for. This module measures, for EVERY one of the 14 files the `PROBES` table +// for. This module measures, for EVERY one of the 15 files the `PROBES` table // names, how far that reach extends — and records the result so it cannot rot. // // ⭐ Since 2026-08-31 two of those files are the ROUTE LEDGERS, and they are @@ -290,7 +290,11 @@ export interface ProbeTableReading { // [#22430] 19 / 14 / 17 -> 19 / 14 / 18: no probe and no file joined; the // `anonymous-deny-api-description` row covers one more key, the `openapi` REST // family, which left the shrink-only ledger baseline. -export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 18 }; +// [#22432] 19 / 14 / 18 -> 20 / 15 / 20: the `/i18n` domain gate joined the +// table as a GATE_PIN on `packages/runtime/src/domains/i18n.ts`, and the +// `anonymous-deny-i18n` row covers its key plus the `/i18n` dispatcher-domain +// key that left the shrink-only ledger baseline. +export const PROBE_TABLE: ProbeTableReading = { entries: 20, files: 15, keys: 20 }; /** * The probe count `authz-conformance.matrix.ts`'s header states. @@ -311,8 +315,10 @@ export const PROBE_TABLE: ProbeTableReading = { entries: 19, files: 14, keys: 18 * * [#21061] 18 -> 19, moved in the same change as the header sentence and the * probe it counts, which is exactly what the inverted pin asks for. + * [#22432] 19 -> 20, the same way: the `/i18n` gate pin and the header + * sentence moved together. */ -export const MATRIX_HEADER_PROBE_CLAIM = 19; +export const MATRIX_HEADER_PROBE_CLAIM = 20; export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // ── the two LEDGER files: the population source since 2026-08-31 ─────── @@ -390,14 +396,21 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ blindSpot: 0, populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)', controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 }, + // [#22432] Re-derived on the merged ref: the note's two counts had stood at + // 11 files / 5 classified / 16 baselined since before the `/analytics` + // domain was classified (#21061), which named one more file and moved one + // more key. The `/i18n` domain names a sixth file and moves a seventh key: + // 15 domain files declare a DomainRoute prefix, 6 of them are named by a + // probe (actions, automation, packages, analytics, i18n, mcp), and the + // baseline's dispatcher block holds 14 of the 21 domains. note: 'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' + '(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' + 'registered domain needs a row, and every ledger domain must be a live prefix or a pinned legacy / ' + 'non-dispatch branch. That two-way guard is what settles the FILE-SELECTION layer by ' + 'construction — all 16 DomainRoute prefixes declared across the 15 domain files that declare one are ' + - 'ledger domains today, including the 11 files no probe has ever named. 21 domains; 5 classified ' + - '(/meta, /actions, /automation, /packages, /mcp), 16 in the shrink-only baseline.', + 'ledger domains today, including the 9 files no probe names. 21 domains; 7 classified ' + + '(/meta, /actions, /automation, /packages, /mcp, /analytics, /i18n), 14 in the shrink-only baseline.', }, { file: 'packages/rest/src/rest-server.ts', @@ -597,6 +610,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ controls: { 'shouldDenyAnonymous(': 1, handleAnalyticsRequest: 3 }, note: 'Same shape as domains/actions.ts.', }, + { + // [#22432] The `/i18n` domain joined the anonymous-deny floor; the gate is + // the handler's first statement, so this row has the actions.ts shape. Its + // handler name occurs 3 times: the declaration, the DomainRoute call site + // and the file docblock's link to it. Its name carries digits, which is + // why the population rule's handler-name class admits them. + file: 'packages/runtime/src/domains/i18n.ts', + kinds: ['GATE_PIN'], + probes: 1, + keys: 1, + population: 1, + reachable: 1, + blindSpot: 0, + populationRule: '`export async function handle*Request` entry points', + controls: { 'shouldDenyAnonymous(': 1, handleI18nRequest: 3 }, + note: 'Same shape as domains/actions.ts.', + }, { file: 'packages/runtime/src/domains/mcp.ts', kinds: ['GATE_PIN'], @@ -923,17 +953,24 @@ export function deriveProbeFileCensus(): { }); } - // ── the five runtime domain files (GATE_PIN) ──────────────────────────── + // ── the six runtime domain files (GATE_PIN) ───────────────────────────── + // + // [#22432] The handler-name class admits digits: `handleI18nRequest` is a + // `handle*Request` entry point like the others, and a letters-only class + // read it as zero — a population of 0 under a reach of 1. No other file's + // count moves with the wider class (none of their handler names carries a + // digit; re-derived). const domains: Array<[string, string, string]> = [ ['packages/runtime/src/domains/actions.ts', 'shouldDenyAnonymous(', 'handleActionsRequest'], ['packages/runtime/src/domains/automation.ts', 'shouldDenyAnonymous(', 'handleAutomationRequest'], ['packages/runtime/src/domains/packages.ts', 'shouldDenyAnonymous(', 'handlePackagesRequest'], ['packages/runtime/src/domains/analytics.ts', 'shouldDenyAnonymous(', 'handleAnalyticsRequest'], + ['packages/runtime/src/domains/i18n.ts', 'shouldDenyAnonymous(', 'handleI18nRequest'], ]; for (const [rel, gate, handler] of domains) { const src = read(rel); files.set(rel, { - population: occurrences(src, /^export async function handle[A-Za-z]+Request/gm), + population: occurrences(src, /^export async function handle[A-Za-z0-9]+Request/gm), reachable: 1, controls: { [gate]: occurrences(src, /shouldDenyAnonymous\s*\(/g), @@ -944,7 +981,7 @@ export function deriveProbeFileCensus(): { { const src = read('packages/runtime/src/domains/mcp.ts'); files.set('packages/runtime/src/domains/mcp.ts', { - population: occurrences(src, /^export async function handle[A-Za-z]+Request/gm), + population: occurrences(src, /^export async function handle[A-Za-z0-9]+Request/gm), reachable: 1, controls: { 'buildMcpBridge(deps, context)': occurrences(src, /buildMcpBridge\(deps, context\)/g), diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts index b74db32d9d5..c7166ac84ed 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.test.ts @@ -43,20 +43,21 @@ describe('authz probe blind-spot census (#13260)', () => { expect(derived.table).toEqual(PROBE_TABLE); }); - it('every classified key is accounted for — 18 `covers` keys, no more', () => { - // The matrix's `covers` keys number 18 today: the 9 probe-minted keys this + it('every classified key is accounted for — 20 `covers` keys, no more', () => { + // The matrix's `covers` keys number 20 today: the 9 probe-minted keys this // census was first measured against, plus the 6 ledger family/domain keys // classified when the population moved (2026-08-31), plus the `/analytics` // domain's gate-pin key and its dispatcher-domain key (#21061), plus the - // `openapi` REST family key (#22430). If a probe + // `openapi` REST family key (#22430), plus the `/i18n` domain's gate-pin + // key and its dispatcher-domain key (#22432). If a probe // starts minting a key no row covers, the ratchet itself goes red as // UNCLASSIFIED — that is its job, and this pin does not duplicate it. What // this asserts is only that the census's key count is current. // - // ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 8 - // are classified here and 31 are enumerated in the shrink-only baseline, + // ⚠️ This is NOT the size of the population. The ledgers mint 39 keys; 9 + // are classified here and 30 are enumerated in the shrink-only baseline, // which `authz-conformance.test.ts` holds to its own four rules. - expect(PROBE_TABLE.keys).toBe(18); + expect(PROBE_TABLE.keys).toBe(20); }); it.each(PROBE_FILE_CENSUS.map((r) => [r.file, r] as const))( diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 5c3282b8624..b57f3b5a4b1 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -51,6 +51,18 @@ // authz-row: anonymous-deny-packages // authz-row: anonymous-deny-analytics // authz-row: anonymous-deny-api-description +// authz-row: anonymous-deny-i18n +// +// ── Why the translation faces are here (#22432) ──────────────────────────── +// +// The `/i18n` dispatcher domain was the one dispatcher domain serving the +// application's own vocabulary without the floor: on a stock boot an +// unauthenticated caller was served the translation bundle — the labels of +// every object, field, app and page — while the metadata read of the same +// objects answered it 401. Each mounted face is driven below anonymously (401 +// in the dispatcher-wrapper envelope, nothing of the bundle served) and with a +// signed-in member as the 200 control, on this one boot, and each anonymous +// denial is classified into the dispatcher-wrapper family. // // ── Why the API-description endpoints are here (#22430) ──────────────────── // @@ -163,6 +175,43 @@ const API_DESCRIPTION_ENDPOINTS: readonly ApiDescriptionEndpoint[] = [ { endpoint: 'the API-description viewer', path: '/docs' }, ]; +// ── #22432 — the translation faces (dispatcher-mounted; runtime domains/i18n.ts) +// +// One handler body serves every face, so the floor is its first statement. The +// locale and the object are the showcase's own (`zh-CN` is a declared +// supported locale with a bundle; `showcase_task` carries translated field +// labels in it), so the member control is a real served answer and not an +// empty one that would make the anonymous 401 vacuous. Labels name the FACE, +// never the request. +interface I18nFace { + readonly face: string; + readonly path: string; + /** What a signed-in member's `data` must carry, so the control is a real serve. */ + readonly expectServed: (data: Record) => void; +} +const I18N_FACES: readonly I18nFace[] = [ + { + face: 'the locale list', path: '/i18n/locales', + expectServed: (data) => { + expect((data.locales as Array<{ code: string }>).map((l) => l.code)).toEqual(expect.arrayContaining(['en', 'zh-CN'])); + }, + }, + { + face: 'the translation bundle', path: '/i18n/translations/zh-CN', + expectServed: (data) => { + expect(data.locale).toBe('zh-CN'); + expect(data.translations?.objects?.showcase_task?.label).toBe('任务'); + }, + }, + { + face: 'the field labels', path: '/i18n/labels/showcase_task/zh-CN', + expectServed: (data) => { + expect(data.object).toBe('showcase_task'); + expect(data.labels?.title?.label).toBe('标题'); + }, + }, +]; + // ── #11373 — the /meta WRITE doors, driven through the REAL mount ────────── // // The `/meta` cases above this line were, for this file's whole life, ONE @@ -696,6 +745,35 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(await r.text(), 'the viewer points at its sibling document').toContain('data-url="/api/v1/openapi.json"'); }); + // ── the translation faces (dispatcher-mounted; runtime domains/i18n.ts) — #22432 + // + // The same domain-wide shape as analytics: the floor is the handler's first + // statement, ahead of the provider probe and every face. The member control + // is the teeth: the same face, with a session, is served 200 with the + // showcase's own bundle, so the anonymous 401 is the floor's answer and the + // face is really mounted and serving on this boot. + it.each(I18N_FACES)('an anonymous caller on $face is denied (401) and served nothing of the bundle', async (face) => { + const r = await anon('GET', face.path); + expect(r.status, `${face.face}: an anonymous caller must be refused by the floor`).toBe(ANONYMOUS_DENY_STATUS); + const body = await r.json(); + expect(declaredFamiliesOf(body), `${face.face}: the refusal is the dispatcher wrapper — got ${JSON.stringify(body)}`).toEqual(['dispatcher-wrapper']); + const { code } = readDenial('dispatcher-wrapper', body); + expect(code, `${face.face}: machine code must be ANONYMOUS_DENY_CODE`).toBe(ANONYMOUS_DENY_CODE); + expect(body, `${face.face}: nothing of the bundle rides on the refusal`).not.toHaveProperty('data'); + const wire = JSON.stringify(body); + for (const leaked of ['translations', 'locales', 'labels', '任务', '标题']) { + expect(wire, `${face.face}: the refusal must not carry ${leaked}`).not.toContain(leaked); + } + }); + + it.each(I18N_FACES)('a signed-in member on $face is served 200 — the control', async (face) => { + const r = await stack.apiAs(memberToken, 'GET', face.path); + expect(r.status, `${face.face}: an authenticated member must clear the floor and be served`).toBe(200); + const body = (await r.json()) as { success?: boolean; data?: Record }; + expect(body.success).toBe(true); + face.expectServed(body.data ?? {}); + }); + // ── one code, one message — two wrappers ─────────────────────────────── it('every denied surface answers the SAME code and message (the wrappers differ)', async () => { const rest = await Promise.all([ @@ -710,6 +788,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => anon('GET', '/packages').then((r) => r.json()), anon('POST', '/packages/anon-probe-pkg/discard-drafts', {}).then((r) => r.json()), ...ANALYTICS_FACES.map((f) => anon(f.method, f.path, f.body).then((r) => r.json())), + ...I18N_FACES.map((f) => anon('GET', f.path).then((r) => r.json())), ]); // Each family is read in ITS OWN declared shape — no `??` chain across the @@ -762,8 +841,9 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // // Coverage, stated as measured rather than as assumed: the dispatcher // domains holding an anonymous gate include ai / meta / security / actions / - // automation / analytics (and /packages, driven above); of those six only - // actions, automation and analytics (#21061) are drivable on THIS boot — + // automation / analytics / i18n (and /packages, driven above); of those seven + // only actions, automation, analytics (#21061) and i18n (#22432) are drivable + // on THIS boot — // probed on the same shared showcase stack these cases use: // - `GET /ai/status` answers 501 `NOT_IMPLEMENTED` (no // `@objectstack/service-ai` ships in the open framework, and that @@ -774,7 +854,7 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // - `/meta` on this stack is served by `@objectstack/rest`, so it exercises // the flat family, not the dispatcher's meta domain. // The wrapper family is therefore represented by actions + automation + - // analytics. Adding a row is the whole change needed the day another domain + // analytics + i18n. Adding a row is the whole change needed the day another domain // becomes reachable. const DENIED_SEAMS: Array<{ seam: string; @@ -818,6 +898,13 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => family: 'dispatcher-wrapper' as DenyFamily, call: () => anon(f.method, f.path, f.body), })), + // [#22432] Labelled by FACE, the same rule. + ...I18N_FACES.map((f) => ({ + seam: f.face, + owner: 'runtime domains/i18n.ts', + family: 'dispatcher-wrapper' as DenyFamily, + call: () => anon('GET', f.path), + })), ]; it.each(DENIED_SEAMS)( diff --git a/packages/runtime/src/domains/i18n-anonymous-deny.test.ts b/packages/runtime/src/domains/i18n-anonymous-deny.test.ts index 22b30fd22a8..f3ffaeab324 100644 --- a/packages/runtime/src/domains/i18n-anonymous-deny.test.ts +++ b/packages/runtime/src/domains/i18n-anonymous-deny.test.ts @@ -115,14 +115,14 @@ const FACES: readonly Face[] = [ face: 'the field labels (path spelling)', subPath: '/labels/lead/fr', malformed: { subPath: '/labels/lead' }, - served: { object: 'lead', locale: 'fr', labels: { company: 'Société' } }, + served: { object: 'lead', locale: 'fr', labels: { company: { label: 'Société' } } }, reads: 'getTranslations', }, { face: 'the field labels (query spelling)', subPath: '/labels/lead', query: { locale: 'fr' }, malformed: { subPath: '/labels/lead', query: {} }, - served: { object: 'lead', locale: 'fr', labels: { company: 'Société' } }, + served: { object: 'lead', locale: 'fr', labels: { company: { label: 'Société' } } }, reads: 'getTranslations', }, ]; From 4a211a3d8559e4f46db9bb024bce68e93146fc38 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:25:17 +0000 Subject: [PATCH 3/8] test(runtime): the /i18n routing suites read as a signed-in caller Six runtime suites drove the /i18n domain with no identity, as the smallest context that compiled. The domain stands on the anonymous-deny floor now, so they carry a session (or the dispatcher's resolution seam is stubbed, as the analytics cases already do); only identity moves and every expectation is unchanged. The multi-tenant suite's probe comment no longer calls the domain anonymous-reachable. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../src/domain-handler-registry.test.ts | 13 +++++- .../src/error-envelope.conformance.test.ts | 4 +- ...ispatcher.multi-tenant-concurrency.test.ts | 15 ++++-- packages/runtime/src/http-dispatcher.test.ts | 46 +++++++++++-------- .../i18n-success-envelope.conformance.test.ts | 20 +++++--- .../src/i18n-supported-locales.test.ts | 12 ++++- 6 files changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 6541010ad3a..fb88cb2a4df 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -146,14 +146,23 @@ describe('HttpDispatcher domain registry (D11 step ③)', () => { }); it('/i18n keeps its in-handler 501 when the i18n service is absent', async () => { - const result = await makeDispatcher().dispatch('GET', '/i18n/locales', undefined, {}, {} as any); + // [#22432] `/i18n` stands on the anonymous-deny floor now, so the + // domain is exercised by a signed-in caller — the resolution seam is + // stubbed exactly as the `/analytics` case below does. Anonymity itself + // is pinned in `domains/i18n-anonymous-deny.test.ts`. + const dispatcher = makeDispatcher(); + (dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'u_i18n', isSystem: false }); + const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, {} as any); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); it('/i18n/locales serves from the i18n service when present', async () => { const i18n = { getLocales: vi.fn().mockReturnValue(['en', 'zh-CN']), getTranslations: vi.fn().mockReturnValue({}) }; - const result = await makeDispatcher({ i18n }).dispatch('GET', '/i18n/locales', undefined, {}, {} as any); + // [#22432] A signed-in caller, as in the case above. + const dispatcher = makeDispatcher({ i18n }); + (dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'u_i18n', isSystem: false }); + const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, {} as any); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'zh-CN']); }); diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index a4338be8f7d..6fd2dd1ec62 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -123,7 +123,9 @@ describe('#3842 — every dispatcher error exit answers in the declared envelope const notAllowed = await makeDispatcher().handleActions('/task/close', 'GET', {}, AUTHED); expect(expectConformantError(notAllowed.response).code).toBe('METHOD_NOT_ALLOWED'); - const notImplemented = await makeDispatcher().handleI18n('/labels/account', 'GET', {}, { request: {} }); + // [#22432] `/i18n` stands on the anonymous-deny floor too, so its 501 + // is read by the same signed-in caller. + const notImplemented = await makeDispatcher().handleI18n('/labels/account', 'GET', {}, AUTHED); expect(expectConformantError(notImplemented.response).code).toBe('NOT_IMPLEMENTED'); }); diff --git a/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts b/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts index e835347e42b..ef0957da6a9 100644 --- a/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts +++ b/packages/runtime/src/http-dispatcher.multi-tenant-concurrency.test.ts @@ -54,9 +54,16 @@ function makeTenantKernel( getObjects: vi.fn(() => ({})), registry: { getObject: vi.fn(() => null), getRegisteredTypes: vi.fn(() => []) }, }; - // The `i18n` slot is the probe: `/i18n/locales` is anonymous-reachable - // (no `shouldDenyAnonymous` gate) and reads the slot straight off the - // request's kernel via `deps.getService('i18n')`. + // The `i18n` slot is the probe: `/i18n/locales` reads the slot straight off + // the request's kernel via `deps.getService('i18n')`. [#22432] The domain + // stands on the anonymous-deny floor now, so each tenant's `auth` slot + // signs the request in (tagged, like every other slot here); the probe is + // still the i18n read behind the floor, and the floor itself is pinned in + // `domains/i18n-anonymous-deny.test.ts`. + const auth = { + __tag: tag, + api: { getSession: async () => ({ user: { id: `usr_${tag}` }, session: { id: `ses_${tag}` } }) }, + }; const i18n = { __tag: tag, getLocales: () => [`${tag}-locale`], @@ -69,7 +76,7 @@ function makeTenantKernel( getObject: async () => undefined, getRegisteredTypes: async () => [tag], }; - const services: Record = { objectql, i18n, metadata }; + const services: Record = { objectql, i18n, metadata, auth }; const kernel: any = { __tag: tag, getServiceAsync: async (name: string) => { diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index de38bc6e45c..0c4874f6738 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -38,6 +38,9 @@ type ContractMock = Partial>; * * [#21061] `/analytics` joined the same floor, so its routing cases take the * same caller; its anonymity is pinned in `domains/analytics-anonymous-deny.test.ts`. + * + * [#22432] `/i18n` joined it too, so its cases take the same caller; its + * anonymity is pinned in `domains/i18n-anonymous-deny.test.ts`. */ const AUTHED_CALLER = () => ({ request: {}, executionContext: { userId: 'u_test', isSystem: false, positions: [], permissions: [], systemPermissions: [] } }) as any; @@ -2593,7 +2596,7 @@ describe('HttpDispatcher', () => { }); it('should list locales via GET /locales', async () => { - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); // Descriptors, not bare codes — the shape `GetLocalesResponseSchema` @@ -2603,7 +2606,7 @@ describe('HttpDispatcher', () => { }); it('should get translations via GET /translations/:locale', async () => { - const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.locale).toBe('zh-CN'); @@ -2614,7 +2617,7 @@ describe('HttpDispatcher', () => { }); it('should get translations via GET /translations?locale=zh-CN (query param)', async () => { - const result = await dispatcher.handleI18n('/translations', 'GET', { locale: 'zh-CN' }, { request: {} }); + const result = await dispatcher.handleI18n('/translations', 'GET', { locale: 'zh-CN' }, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.locale).toBe('zh-CN'); @@ -2622,14 +2625,14 @@ describe('HttpDispatcher', () => { }); it('should return 400 when translations requested without locale', async () => { - const result = await dispatcher.handleI18n('/translations', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(400); expect(result.response?.body?.error?.message).toBe('Missing locale parameter'); }); it('should get field labels via GET /labels/:object/:locale', async () => { - const result = await dispatcher.handleI18n('/labels/account/zh-CN', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/labels/account/zh-CN', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.object).toBe('account'); @@ -2639,7 +2642,7 @@ describe('HttpDispatcher', () => { }); it('should get field labels via GET /labels/:object?locale=zh-CN (query param)', async () => { - const result = await dispatcher.handleI18n('/labels/account', 'GET', { locale: 'zh-CN' }, { request: {} }); + const result = await dispatcher.handleI18n('/labels/account', 'GET', { locale: 'zh-CN' }, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.object).toBe('account'); @@ -2647,7 +2650,7 @@ describe('HttpDispatcher', () => { }); it('should return 400 when labels requested without locale', async () => { - const result = await dispatcher.handleI18n('/labels/account', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/labels/account', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(400); expect(result.response?.body?.error?.message).toBe('Missing locale parameter'); @@ -2668,7 +2671,7 @@ describe('HttpDispatcher', () => { * scene of the original pin. */ it('emits an ApiErrorSchema-conformant error body (#3842, was the #3675 pin)', async () => { - const result = await dispatcher.handleI18n('/translations', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations', 'GET', {}, AUTHED_CALLER()); const body = result.response?.body as { success?: boolean; error?: unknown }; expect(body.success).toBe(false); @@ -2718,7 +2721,7 @@ describe('HttpDispatcher', () => { messages: { save: 'Save' }, }); - const result = await dispatcher.handleI18n('/labels/contact/en', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/labels/contact/en', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); // Entries are objects carrying help/options, per @@ -2737,7 +2740,7 @@ describe('HttpDispatcher', () => { objects: { contact: { fields: { email: { label: 'Email' } } } }, }); - const result = await dispatcher.handleI18n('/labels/account/en', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/labels/account/en', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.labels).toEqual({}); @@ -2747,17 +2750,18 @@ describe('HttpDispatcher', () => { (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); it('should return unhandled for non-GET methods', async () => { - const result = await dispatcher.handleI18n('/locales', 'POST', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'POST', {}, AUTHED_CALLER()); expect(result.handled).toBe(false); }); it('should dispatch /i18n routes via dispatch()', async () => { + signInDispatchCaller(dispatcher); const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} }); expect(result.handled).toBe(true); expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'zh-CN', 'ja']); @@ -2770,7 +2774,7 @@ describe('HttpDispatcher', () => { return {}; }); - const result = await dispatcher.handleI18n('/translations/zh', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations/zh', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); const data = result.response?.body?.data; @@ -2786,7 +2790,7 @@ describe('HttpDispatcher', () => { return {}; }); - const result = await dispatcher.handleI18n('/translations/ZH-CN', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations/ZH-CN', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); const data = result.response?.body?.data; @@ -2921,7 +2925,7 @@ describe('HttpDispatcher', () => { expect(info.services.i18n.status).toBe('available'); // Handler should also find it - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'fr']); @@ -3455,7 +3459,7 @@ describe('HttpDispatcher', () => { it('/i18n — a stub slot answers the not-available 501; a degraded provider serves', async () => { const stub = stubbed({ getLocales: vi.fn().mockReturnValue(['xx']) }); serveOnly('i18n', stub); - const stubResult = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const stubResult = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(stubResult.response?.status).toBe(501); expect(stub.getLocales).not.toHaveBeenCalled(); @@ -3464,7 +3468,7 @@ describe('HttpDispatcher', () => { getDefaultLocale: vi.fn().mockReturnValue('en'), }); serveOnly('i18n', svc); - const okResult = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const okResult = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(okResult.response?.status).toBe(200); expect(svc.getLocales).toHaveBeenCalled(); }); @@ -3686,7 +3690,7 @@ describe('HttpDispatcher', () => { expect(info.locale.supported).toEqual(['en', 'zh-CN']); // Handler should serve translations - const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, AUTHED_CALLER()); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.translations['o.task.label']).toBe('任务'); }); @@ -3705,6 +3709,7 @@ describe('HttpDispatcher', () => { }); // MSW-style dispatch: full path stripped to relative + signInDispatchCaller(dispatcher); const localesResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} }); expect(localesResult.handled).toBe(true); expect(localesResult.response?.body?.data?.locales.map((l: any) => l.code)).toEqual(['en', 'de']); @@ -3729,10 +3734,11 @@ describe('HttpDispatcher', () => { expect(info.services.i18n.status).toBe('unavailable'); // Handler: 501 - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(result.response?.status).toBe(501); // Dispatch: also 501 + signInDispatchCaller(dispatcher); const dispatchResult = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} }); expect(dispatchResult.response?.status).toBe(501); }); @@ -3758,7 +3764,7 @@ describe('HttpDispatcher', () => { const info = await dispatcher.getDiscoveryInfo('/api/v1'); expect(info.services.i18n.enabled).toBe(true); - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} }); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, AUTHED_CALLER()); expect(result.response?.status).toBe(200); }); }); diff --git a/packages/runtime/src/i18n-success-envelope.conformance.test.ts b/packages/runtime/src/i18n-success-envelope.conformance.test.ts index 4c9dd1926dd..41cc154e453 100644 --- a/packages/runtime/src/i18n-success-envelope.conformance.test.ts +++ b/packages/runtime/src/i18n-success-envelope.conformance.test.ts @@ -64,6 +64,14 @@ const BUNDLE = { messages: { save: '保存' }, }; +/** + * [#22432] `/i18n` stands on the anonymous-deny floor now. These cases are + * about the SUCCESS body a served caller receives, not about who may call, so + * they carry a session; anonymity is pinned in + * `domains/i18n-anonymous-deny.test.ts`. + */ +const SIGNED_IN = { request: {}, executionContext: { userId: 'u_test', isSystem: false } } as never; + describe('/i18n success-envelope conformance (dispatcher domain)', () => { let dispatcher: HttpDispatcher; let i18nService: Record; @@ -93,7 +101,7 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => { } it('GET /locales — body satisfies GetLocalesResponseSchema', async () => { - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); expectEnvelope(result.response?.body); @@ -109,14 +117,14 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => { }); it('GET /locales — a bare string[] is DEAD, so a revert cannot pass quietly', async () => { - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, SIGNED_IN); const locales = result.response?.body?.data?.locales as unknown[]; expect(locales.every((l) => typeof l === 'object' && l !== null)).toBe(true); expect(locales).not.toContain('en'); }); it('GET /translations/:locale — body satisfies GetTranslationsResponseSchema', async () => { - const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); expectEnvelope(result.response?.body); @@ -128,7 +136,7 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => { }); it('GET /labels/:object/:locale — body satisfies GetFieldLabelsResponseSchema', async () => { - const result = await dispatcher.handleI18n('/labels/contact/zh-CN', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/labels/contact/zh-CN', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); expectEnvelope(result.response?.body); @@ -144,7 +152,7 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => { }); it('an empty label map is still a conforming body', async () => { - const result = await dispatcher.handleI18n('/labels/unknown_object/zh-CN', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/labels/unknown_object/zh-CN', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); expect(GetFieldLabelsResponseSchema.safeParse(result.response?.body?.data).success).toBe(true); }); @@ -156,7 +164,7 @@ describe('/i18n success-envelope conformance (dispatcher domain)', () => { */ it('GET /locales conforms even when the provider omits getDefaultLocale', async () => { delete i18nService.getDefaultLocale; - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, SIGNED_IN); const parsed = GetLocalesResponseSchema.safeParse(result.response?.body?.data); expect(parsed.success).toBe(true); expect(parsed.data?.locales.every((l) => l.isDefault === false)).toBe(true); diff --git a/packages/runtime/src/i18n-supported-locales.test.ts b/packages/runtime/src/i18n-supported-locales.test.ts index 64c36cb3261..ade22772ea5 100644 --- a/packages/runtime/src/i18n-supported-locales.test.ts +++ b/packages/runtime/src/i18n-supported-locales.test.ts @@ -101,9 +101,17 @@ async function bootStack(i18nConfig: Record | undefined) { return { i18n, ctx, dispatcher: new HttpDispatcher(kernel as never) }; } +/** + * [#22432] `/i18n` stands on the anonymous-deny floor now. These cases are + * about WHICH locales a served caller is offered, not about who may call, so + * they carry a session; anonymity is pinned in + * `domains/i18n-anonymous-deny.test.ts`. + */ +const SIGNED_IN = { request: {}, executionContext: { userId: 'u_test', isSystem: false } } as never; + /** `GET /i18n/locales`, parsed with the schema that declares its body. */ async function getLocales(dispatcher: HttpDispatcher) { - const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/locales', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); const parsed = GetLocalesResponseSchema.safeParse(result.response?.body?.data); expect( @@ -184,7 +192,7 @@ describe('GET /i18n/locales reports the app\'s declared supportedLocales (#7679) // unloading bundles: `GET /i18n/translations/ja-JP` still answers. const { dispatcher } = await bootStack({ defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }); - const result = await dispatcher.handleI18n('/translations/ja-JP', 'GET', {}, { request: {} } as never); + const result = await dispatcher.handleI18n('/translations/ja-JP', 'GET', {}, SIGNED_IN); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.translations) .toEqual({ objects: { sys_user: { label: 'ユーザー' } } }); From 5b97d08ae4f5ffc4f41ce61c5d07ac0f49f23387 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:37:44 +0000 Subject: [PATCH 4/8] chore(objectui): bump the console pin to 47b1f0bb7174 (carries the sign-in translations companion) Written by the pin-update procedure, no hand edits: scripts/bump-objectui.sh 47b1f0bb7174 --no-commit (the pin and the @objectstack/console changeset), pnpm objectui:build, then node scripts/gen-sdui-manifest-node.mjs (the manifest record; the manifest bytes did not move) and pnpm gen:sdui-lockstep against the pinned objectui worktree (the lockstep record). objectui@47b1f0bb71748a7d16f36edecc50059367d2e35a Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .changeset/console-47b1f0bb7174.md | 23 +++++++++++++++++++++ .objectui-sha | 2 +- packages/sdui-parser/objectui-lockstep.json | 6 +++--- scripts/sdui-manifest.record.json | 4 ++-- 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 .changeset/console-47b1f0bb7174.md diff --git a/.changeset/console-47b1f0bb7174.md b/.changeset/console-47b1f0bb7174.md new file mode 100644 index 00000000000..5fe991ec3b4 --- /dev/null +++ b/.changeset/console-47b1f0bb7174.md @@ -0,0 +1,23 @@ +--- +"@objectstack/console": patch +--- + +Console (objectui) refreshed to `47b1f0bb7174`. Frontend changes in this range: + +Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 14 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). + +- **patch** — The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). (objectui `47b1f0bb7`) +- **patch** — The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally… (objectui `ba9e82026`) +- **patch** — The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word t… (objectui `8de8ba280`) +- **patch** — The embedded item editor ("Save into object", opened from a metadata item's Related drawer) now saves into the parent's draft (objectui#12027). (objectui `ea79b7777`) +- **patch** — A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#27… (objectui `049012bf0`) +- **patch** — Studio navigation details (objectui#11794): (objectui `8f815f4fe`) +- **patch** — Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" an… (objectui `8f8f760fa`) +- **patch** — Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes… (objectui `6694abe75`) +- **patch** — Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the nav… (objectui `5382a865f`) +- **patch** — Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after… (objectui `2063f7a96`) +- **patch** — A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). (objectui `16b9d440d`) +- **patch** — Four metadata-admin pickers use the shared `Select`, the control the rest of the console picks with (objectui#11865, the metadata-admin previews and inspectors' part of that card)… (objectui `f2bff5ce8`) +- **patch** — The Create View dialog, the AI build panel's Excel import bar and the API console's method selector pick with the shared `Select`, the control the rest of the console picks with (… (objectui `869d0bfdf`) + +objectui range: `f0268ad78485...47b1f0bb7174` diff --git a/.objectui-sha b/.objectui-sha index 4a89f6917c7..a8091500d35 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -f0268ad784854568aa58a2aa791f6a7502259186 +47b1f0bb71748a7d16f36edecc50059367d2e35a diff --git a/packages/sdui-parser/objectui-lockstep.json b/packages/sdui-parser/objectui-lockstep.json index a96f26f7aaa..b5909a31d97 100644 --- a/packages/sdui-parser/objectui-lockstep.json +++ b/packages/sdui-parser/objectui-lockstep.json @@ -6,8 +6,8 @@ ], "objectui": { "repo": "https://github.com/objectstack-ai/objectui.git", - "rev": "f0268ad784854568aa58a2aa791f6a7502259186", - "revDate": "2026-10-09T01:49:47+00:00", + "rev": "47b1f0bb71748a7d16f36edecc50059367d2e35a", + "revDate": "2026-10-09T08:34:00+00:00", "source": "packages/sdui-parser/src", "files": [ "packages/sdui-parser/src/body-dialect.ts", @@ -21,7 +21,7 @@ "packages/sdui-parser/src/validate.ts" ] }, - "recordedAgainstPin": "f0268ad784854568aa58a2aa791f6a7502259186", + "recordedAgainstPin": "47b1f0bb71748a7d16f36edecc50059367d2e35a", "grammarRegion": { "file": "packages/sdui-parser/src/parse.ts", "delimiter": "/* ---------------------- the JS literal subset (#6614) ---------------------- */", diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 032af6469cb..51cb4b7548d 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -11,9 +11,9 @@ "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", "Regenerate + re-record: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs" ], - "objectuiSha": "f0268ad784854568aa58a2aa791f6a7502259186", + "objectuiSha": "47b1f0bb71748a7d16f36edecc50059367d2e35a", "source": "built-tree", - "modulesRoot": ".cache/objectui-f0268ad78485/apps/console", + "modulesRoot": ".cache/objectui-47b1f0bb7174/apps/console", "objectuiWorkspaceVersion": "17.7.0", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-10-09", From 002e6bbcac84e2e7a4014f0eda1794908f3b8838 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:15:18 +0000 Subject: [PATCH 5/8] chore(spec): re-measure the objectui pin citations at 47b1f0bb7174 Every record that asserted the old pin f0268ad78 is re-read at the new pin by the gate's procedure: the cited objectui file read at 47b1f0bb7, its line numbers re-derived there, and the anchor and the sha moved together. Nine records cite ObjectGrid.tsx or ListView.tsx, whose cited lines moved with their text byte-identical; the other 34 cite only files byte-identical across the hop. The six migration entries' corpus counts are re-taken at the new pin; registry.ts is regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- .../objectui-pin-citations-47b1f0bb7174.md | 15 ++ .../api-methods-batch-conformance.test.ts | 14 +- .../src/kernel/functional-completeness.ts | 32 +++- ...ck-and-hot-reload-durations-unit-in-key.ts | 6 +- ...rnel-runtime-config-timeout-unit-in-key.ts | 6 +- .../18.logging-durations-unit-in-key.ts | 6 +- ...tem-metrics-jsdoc-durations-unit-in-key.ts | 9 +- ...ing-otel-exporter-durations-unit-in-key.ts | 9 +- .../18.tenant-schema-cache-ttl-unit-in-key.ts | 6 +- packages/spec/src/migrations/registry.ts | 42 ++--- .../src/ui/action-outcome-messages.test.ts | 3 + packages/spec/src/ui/action.zod.ts | 3 + packages/spec/src/ui/component.test.ts | 32 +++- packages/spec/src/ui/component.zod.ts | 159 +++++++++++++++--- packages/spec/src/ui/dataset.zod.ts | 3 + packages/spec/src/ui/view.zod.ts | 10 +- 16 files changed, 275 insertions(+), 80 deletions(-) create mode 100644 .changeset/objectui-pin-citations-47b1f0bb7174.md diff --git a/.changeset/objectui-pin-citations-47b1f0bb7174.md b/.changeset/objectui-pin-citations-47b1f0bb7174.md new file mode 100644 index 00000000000..ec789811280 --- /dev/null +++ b/.changeset/objectui-pin-citations-47b1f0bb7174.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `47b1f0bb7174`. + +Clause-②: no + +Every anchor was mapped through the objectui diff `f0268ad78485..47b1f0bb7174`: 129 paths over 14 commits, none deleted or renamed. Two files a record cites changed on the hop, each for objectui#11865 (a picker drawn with the shared `Select`): `ObjectGrid.tsx` gained one import line and redrew its grouped pager's rows-per-page picker, and `ListView.tsx` redrew its "Color by field" and rows-per-page pickers. Every cited line in those two files moved with its text byte-identical, so the nine records that cite them are re-pointed, and each hop sentence says by how much. The other 34 records cite only files that are byte-identical across the hop, and each gains a hop sentence that says so. + +No record says anything false at the new pin, so no reading is rewritten. + +The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `f0268ad78485` number. The corpus is now 8281 tracked files. Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. The controls moved with the corpus, for example `objectstack` from 17956 to 17980 and `timeout` from 1658 to 1674. + +No key, default, enum member or export moves. diff --git a/packages/spec/src/data/api-methods-batch-conformance.test.ts b/packages/spec/src/data/api-methods-batch-conformance.test.ts index 6259723603b..91bd4a34bd4 100644 --- a/packages/spec/src/data/api-methods-batch-conformance.test.ts +++ b/packages/spec/src/data/api-methods-batch-conformance.test.ts @@ -61,7 +61,19 @@ const SINGLE_RECORD_WRITE_ONLY: Record = { // `revoked` on ONE key. The multi-select surface this rule protects does not // exist for API keys, and the shape a future one would take does not need // `bulk` either — both read off the console build this release pins - // (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + // (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + // in `packages/plugin-grid`, on the hop off `f0268ad78`, `ObjectGrid.tsx` + // changed again (19 insertions, 11 deletions: objectui#11865 slice m, + // objectui `2063f7a96`: one import line, and the grouped pager's + // rows-per-page picker drawn with the shared `Select`), none of it inside the + // selection block and no hunk naming a bulk action, a delete affordance or + // `operations`, so the block only MOVED by +1: `ObjectGrid.tsx:4868-4895` + // here (`4867-4894` at `f0268ad78`), still hashing to + // `c88443302d40c2db739ddb235470bafa29056e2e` (hash-object of the block), + // re-READ with the same reading below; `hooks/useBulkExecutor.ts` is + // byte-identical to `f0268ad78` and its `:298-303` still hashes to + // `01083348330f10a201cdf1078b4c21c236402b6a`. At `f0268ad78`, re-read there + // 2026-10-09: // in `packages/plugin-grid`, on the hop off `a58626c88`, `ObjectGrid.tsx` // changed again (85 insertions, 27 deletions: objectui#11880 item 5's grid // filter hand-off, objectui#6152 rounds 7 and 10, objectui#11817's link cell, diff --git a/packages/spec/src/kernel/functional-completeness.ts b/packages/spec/src/kernel/functional-completeness.ts index 70ec2c89773..7f647dd8f5d 100644 --- a/packages/spec/src/kernel/functional-completeness.ts +++ b/packages/spec/src/kernel/functional-completeness.ts @@ -298,7 +298,15 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { * English sentence. So the loss is total rather than partial — every * record, on every object — and the author is told at render time as well * as here. Both reads hold at the pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * on the hop off `f0268ad78` `ObjectCalendar.tsx` is byte-identical (`git diff + * --quiet`), so `getCalendarConfig` `:294`, the `if (!calendarConfig)` arm + * `:1296` and its `tt(…)` lines `:1303-1304` did not move; `ListView.tsx` + * changed (+85/-26: objectui#11865 slice p, objectui `8f8f760fa`: the "Color by + * field" and rows-per-page pickers drawn with the shared `Select`), every + * `case` arm byte-identical and moved by 60; and the `en` / `zh` / `de` packs + * are byte-identical, their `calendar.configRequired` strings with them. At + * `f0268ad78`, re-read there 2026-10-09: * on the hop off `a58626c88` `ObjectCalendar.tsx` changed in one comment line * only, line for line (`:1250`, objectui `c0862c1cc` re-citing the installed spec * as 17.7.0), so `getCalendarConfig` `:294`, the `if (!calendarConfig)` arm @@ -384,7 +392,12 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { * This repo already records the same deletion one door over: the #13817 * check in `../ui/view.zod.ts` names objectui#7029 as its runtime half. * - `gantt` → NO fallback, and no silence [#19630]. Measured at the pin - * this repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * this repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * on the hop off `f0268ad78` `ObjectGantt.tsx` is byte-identical (`git diff + * --quiet`), so `getGanttConfig` `:610` and the `if (!ganttConfig)` arm `:2307` + * did not move, and `ListView.tsx` changed (+85/-26, objectui#11865 slice p, as + * above) with its `case 'gantt'` byte-identical, moved by 60. At `f0268ad78`, + * re-read there 2026-10-09: * on the hop off `a58626c88` `ObjectGantt.tsx` is byte-identical * (`git diff --quiet`), so `getGanttConfig` `:610` and the `if (!ganttConfig)` * arm `:2307` did not move, and `ListView.tsx` changed (+286/-46, the same eight @@ -457,7 +470,13 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { * requires. So the view does not draw a blank chart: it refuses, by name. * - `timeline` → date axis: NO fallback [#19630]; title: `titleField || 'name'`, * which still stands. Measured at the same pin (`.objectui-sha` = - * `f0268ad78`, re-read there 2026-10-09: + * `47b1f0bb7`, re-read there 2026-10-09: + * on the hop off `f0268ad78` `ObjectTimeline.tsx` is byte-identical (`git diff + * --quiet`), so its start-date chain `:589-591` and its `if (!hasAuthoredItems + * && !startDateField)` refusal arm `:952` did not move, and `ListView.tsx` + * changed (+85/-26) with its `case 'timeline'` byte-identical, moved by 60, and + * `resolveTimelineDateBinding` byte-identical and unmoved. At `f0268ad78`, + * re-read there 2026-10-09: * on the hop off `a58626c88` `ObjectTimeline.tsx` changed in three comments only * (+19/-8, objectui#6152 round 12: the nested block's type is the spec slot by * reference, its legacy `dateField` refused by name while the read stays), so its @@ -526,7 +545,12 @@ export function checkFieldCompleteness(def: unknown): CompletenessFinding[] { * block does render; the warning still fires there, because the block the * view TYPE names is the one that is missing. Unchanged by this row. * - `map` → NO fallback, and no silence [#19630]. Measured at the same - * pin (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * pin (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * on the hop off `f0268ad78` `ObjectMap.tsx` is byte-identical (`git diff + * --quiet`), so `getMapConfig` `:421`, `hasCoordinateBinding` `:527` and the + * refusal arm `:1337` did not move; and `ListView.tsx` changed (+85/-26) with + * its `case 'map'` byte-identical, moved by 60, and `resolveListMapConfig` + * byte-identical, unmoved at `:147`. At `f0268ad78`, re-read there 2026-10-09: * on the hop off `a58626c88` `ObjectMap.tsx` changed (85 insertions, 4 deletions, * objectui#11819: without WebGL2 the map lists its records instead of crashing), * which touches no coordinate read: `getMapConfig` and `hasCoordinateBinding` are diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts index 6afbfc8077e..ae477e52c21 100644 --- a/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-health-check-and-hot-reload-durations-unit-in-key.ts @@ -52,13 +52,13 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names ' + + 'against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — 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 8234 tracked files (0 across 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 ' + + 'the string debounceDelay each occur 0 times across its 8281 tracked files (0 across 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 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces ' + + '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 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces ' + 'every earlier count).', acceptanceCriteria: 'Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every ' diff --git a/packages/spec/src/migrations/entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts index d6da9681075..154b2deacf2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.kernel-runtime-config-timeout-unit-in-key.ts @@ -46,10 +46,10 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — ' + + '`.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — ' + 'spells resourceLimits.timeout 0 times across ' - + '8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits ' - + '2 on the same corpus (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 ' + + '8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits ' + + '2 on the same corpus (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.', diff --git a/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts index 4f8ac62b0a7..f6e13d864b6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.logging-durations-unit-in-key.ts @@ -47,10 +47,10 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells ' + + 'objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells ' + '`flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 ' - + 'across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on ' - + 'the same corpus (all four 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, ' + + 'across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on ' + + 'the same corpus (all four 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).', acceptanceCriteria: diff --git a/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts index 7aed489e334..4bcf5ee3ed8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.system-metrics-jsdoc-durations-unit-in-key.ts @@ -73,10 +73,11 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — ' - + 'spells all six metrics def names and both distinctive keys 0 times across 8234 tracked ' - + 'files at that sha, against lit controls window 4430, timeout 1658, period 249, ' - + 'interval 213 and metrics 404 on that same corpus and sha (0 across 7754, against 4255 / ' + + '`.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — ' + + 'spells all six metrics def names and both distinctive keys 0 times across 8281 tracked ' + + 'files at that sha, against lit controls window 4449, timeout 1674, period 249, ' + + 'interval 213 and metrics 455 on that same corpus and sha (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 / ' diff --git a/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts index 40fde1ff536..89458eb32e3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.system-tracing-otel-exporter-durations-unit-in-key.ts @@ -69,12 +69,13 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of ' - + 'tracing.zod.ts and each of the four key names occur 0 times across the 8234 files ' + + 'pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of ' + + 'tracing.zod.ts and each of the four key names occur 0 times across the 8281 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: 17956 hits for the bare token objectstack, ' - + 'and 7522 for the package specifier @objectstack/spec (at a58626c88: 0 across 7754, Span 509, ' + + 'two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, ' + + 'and 7523 for the package specifier @objectstack/spec (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, ' diff --git a/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts index fe452dfdba3..c1c88fd636f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.tenant-schema-cache-ttl-unit-in-key.ts @@ -31,9 +31,9 @@ export const entry: SemanticMigration = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 ' - + 'times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the ' - + 'same corpus (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 ' + + 'objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 ' + + 'times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the ' + + 'same corpus (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).', acceptanceCriteria: 'Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ed7eda14f48..36e15531758 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -15211,13 +15211,13 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names ' + + 'against, `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — 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 8234 tracked files (0 across 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 ' + + 'the string debounceDelay each occur 0 times across its 8281 tracked files (0 across 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 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces ' + + '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 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces ' + 'every earlier count).', acceptanceCriteria: 'Every producer and reader of a PluginHealthCheck spells intervalMs and timeoutMs, and every ' @@ -15422,10 +15422,10 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — ' + + '`.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — ' + 'spells resourceLimits.timeout 0 times across ' - + '8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits ' - + '2 on the same corpus (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 ' + + '8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits ' + + '2 on the same corpus (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.', @@ -15661,10 +15661,10 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells ' + + 'objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells ' + '`flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 ' - + 'across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on ' - + 'the same corpus (all four 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, ' + + 'across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on ' + + 'the same corpus (all four 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).', acceptanceCriteria: @@ -20114,10 +20114,11 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — ' - + 'spells all six metrics def names and both distinctive keys 0 times across 8234 tracked ' - + 'files at that sha, against lit controls window 4430, timeout 1658, period 249, ' - + 'interval 213 and metrics 404 on that same corpus and sha (0 across 7754, against 4255 / ' + + '`.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — ' + + 'spells all six metrics def names and both distinctive keys 0 times across 8281 tracked ' + + 'files at that sha, against lit controls window 4449, timeout 1674, period 249, ' + + 'interval 213 and metrics 455 on that same corpus and sha (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 / ' @@ -20339,12 +20340,13 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of ' - + 'tracing.zod.ts and each of the four key names occur 0 times across the 8234 files ' + + 'pinned objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of ' + + 'tracing.zod.ts and each of the four key names occur 0 times across the 8281 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: 17956 hits for the bare token objectstack, ' - + 'and 7522 for the package specifier @objectstack/spec (at a58626c88: 0 across 7754, Span 509, ' + + 'two lit controls on that same corpus and sha: 17980 hits for the bare token objectstack, ' + + 'and 7523 for the package specifier @objectstack/spec (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, ' @@ -20460,9 +20462,9 @@ const step18: MigrationStep = { + '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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 ' - + 'times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the ' - + 'same corpus (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 ' + + 'objectui checkout — `.objectui-sha` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 ' + + 'times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the ' + + 'same corpus (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).', acceptanceCriteria: 'Every schema-level tenant isolation source spells `performance.schemaCacheTtlSeconds`; ' diff --git a/packages/spec/src/ui/action-outcome-messages.test.ts b/packages/spec/src/ui/action-outcome-messages.test.ts index f5088168e4e..14ef49579f4 100644 --- a/packages/spec/src/ui/action-outcome-messages.test.ts +++ b/packages/spec/src/ui/action-outcome-messages.test.ts @@ -12,6 +12,9 @@ * runner default) and the `${result.*}` INTERPOLATION are the console's — the * reader is objectstack-ai/objectui#11344, a later link of the same ruling, * carried from the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * `ActionRunner.composeSuccessMessage`, in `core/src/actions/ActionRunner.ts`, is * byte-identical in body to `a58626c88` and MOVED by 7 (objectui#11695 added the diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index dd745b7cf0d..bd0f0db354c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1364,6 +1364,9 @@ const actionObject = () => strictObject({ * * Liveness: the ledger row was `planned` until the console reader landed, * and it is `live` from the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * `core/src/actions/ActionRunner.ts` changed above the reader only (+7/-0, * objectui#11695: the confirmation handler's `destructive` option and its diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index bac7e104fb2..f6ea568218f 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -288,7 +288,10 @@ describe('PageAccordionProps variant — declared because the accordion renderer // same file's `ComponentRegistry.register('accordion', …)` publishes the key to // the Studio block designer at `:1222` (the `items` input, documented as // `[{ label, icon?, collapsed?, children }]`). Measured at the pin this repo -// builds against — `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// builds against — `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// every objectui file this record cites is byte-identical across the hop from +// `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At +// `f0268ad78`, re-read there 2026-10-09: // `containers.tsx` changed across the hop from `a58626c88` (+92/-6, // objectui#11811: a record action disabled by its predicate says why, in the // `page:header` renderer), every hunk at `:1512` or below, so both anchors were @@ -445,7 +448,10 @@ describe('PageTabsProps items[].value / items[].count — declared because the t // same file's `ComponentRegistry.register('tabs', …)` publishes the key to the // Studio block designer at `:1007` (the `items` input, documented as // `[{ label, value?, icon?, count?, visibleWhen?, children }]`). Measured at -// the pin this repo builds against — `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// the pin this repo builds against — `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// every objectui file this record cites is byte-identical across the hop from +// `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At +// `f0268ad78`, re-read there 2026-10-09: // `containers.tsx` changed across the hop from `a58626c88` (+92/-6, // objectui#11811: a record action disabled by its predicate says why, in the // `page:header` renderer), every hunk at `:1512` or below, so both anchors were @@ -3709,7 +3715,10 @@ describe('object-* block props schemas — declared, so the props gate has a sch // #16503 — the spec half of objectui#8172 (decision batch #68, 2026-09-07, // option A: the contract declares the capability that already ships, is // documented and is in use). Measured at the objectui pin this repo builds -// against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// every objectui file this record cites is byte-identical across the hop from +// `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At +// `f0268ad78`, re-read there 2026-10-09: // `ObjectKanban.tsx`, `plugin-kanban/src/types.ts` and `plugin-kanban.mdx` are // byte-identical to `a58626c88` (`git diff --quiet`), so the `$top` read // `:762`, the default `:98`, the `limit` row and the `limit: 250` snippet did @@ -3897,7 +3906,12 @@ describe('ObjectKanbanPropsSchema limit — the row cap four objectui faces alre // since retired that block, objectui#8257). Unlike `limit` above — a key four // objectui faces already implemented, so the spec was the half that was wrong // — `quickAdd` was FORWARDED and never read: at the pin this repo builds -// against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// `KanbanBoardCore.tsx`, `ObjectKanban.tsx` and `KanbanImpl.tsx` are +// byte-identical across the hop from `f0268ad78` (`git diff --quiet`), so +// `:78`, `:111-112`, the spread `:1731` and the gate `:668` / `:681` did not +// move, and the counts re-read the same, 2 / 2 / 11. At `f0268ad78`, re-read +// there 2026-10-09: // `KanbanBoardCore.tsx`, `ObjectKanban.tsx` and `KanbanImpl.tsx` are // byte-identical across the hop from `a58626c88` (`git diff --quiet`), so // `:78`, `:111-112`, the spread `:1731` and the gate `:668` / `:681` did not @@ -4075,7 +4089,10 @@ describe('ObjectKanbanPropsSchema quickAdd is retired', () => { // #9881 and commit 60e0f900a recorded the accordion and tab items; these two close the set. // // The button record re-measured at the pin this repo builds against — -// `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// every objectui file this record cites is byte-identical across the hop from +// `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At +// `f0268ad78`, re-read there 2026-10-09: // every objectui file this record cites is byte-identical across the hop from // `a58626c88` (`git diff --quiet`), so every anchor held unmoved. At // `a58626c88`, re-read there 2026-10-06: @@ -4249,7 +4266,10 @@ describe('ObjectMetricPropsSchema icon liveness', () => { // // The acceptance the card names, pinned: each row's KEY SET is the one the // renderer's read points support at the pin this repo builds against -// (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: +// (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: +// every objectui file this record cites is byte-identical across the hop from +// `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At +// `f0268ad78`, re-read there 2026-10-09: // `ObjectGantt.tsx`, `ObjectTree.tsx` and `record-source.ts` are byte-identical // across the hop off `a58626c88` (`git diff --quiet`), so every anchor in them // held unmoved, the tree's three reads at `:714`, `:945` and `:1135`; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index da77709a098..3d4bd238299 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -830,7 +830,10 @@ export const PageTabsProps = strictObject({ * false candidate a component over). * * The key is LIVE at the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop + * from `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `containers.tsx` changed across the hop from `a58626c88` (+92/-6, * objectui#11811: a record action disabled by its predicate says why, in the * `page:header` renderer), every hunk at `:1512` or below, so both anchors @@ -1061,7 +1064,10 @@ export const PageCardProps = strictObject({ * declarations identical. * * Each clause is read off the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78485`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7174`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78485` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78485`, re-read there 2026-10-09: * across the hop from `a58626c88dc8` `record-details.tsx`, * `record-highlights.tsx` and the three `permissions` files are byte-identical * (`git diff --quiet`), so NO anchor in those five moved; @@ -2387,7 +2393,10 @@ export const PageAccordionProps = strictObject({ * re-derive the same false candidate). * * The key is LIVE at the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop + * from `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `containers.tsx` changed across the hop from `a58626c88` (+92/-6, * objectui#11811: a record action disabled by its predicate says why, in the * `page:header` renderer), every hunk at `:1512` or below, so both anchors @@ -2740,7 +2749,10 @@ export const ElementButtonPropsSchema = lazySchema(() => strictObject({ * the button. * * The key is LIVE at the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * every objectui file this record cites is byte-identical across the hop from * `a58626c88` (`git diff --quiet`), so every anchor held unmoved. At * `a58626c88`, re-read there 2026-10-06: @@ -3232,7 +3244,10 @@ export const ElementTextInputPropsSchema = lazySchema(() => strictObject({ * had no row for — `action:button`, `action:group`, `action:menu`, * `action:icon`, `element:definition-list`, `element:repeater` * (`core/src/registry/public-blocks.ts:117-122` at the pin this repo builds - * against, `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * against, `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `public-blocks.ts`, `auto-trigger.ts`, `static-params.ts` and `ui/button.tsx` * are byte-identical across the hop from `a58626c88` (`git diff --quiet`), so * their anchors held unmoved. All four `action-*.tsx` renderers changed for @@ -3764,7 +3779,10 @@ export type ActionIconPropsParsed = z.infer; * inventory. A member's `params` is its `ActionParam[]` input list: both * containers forward an array as `actionParams` (group `:346-348`, menu * `:270-272`, re-read at the pin this repo builds against, `.objectui-sha` = - * `f0268ad78`, 2026-10-09: both MOVED with their text byte-identical, by 21 + * `47b1f0bb7`, 2026-10-09: `action-group.tsx`, `action-menu.tsx` and + * `static-params.ts` are byte-identical across the hop from `f0268ad78` (`git + * diff --quiet`), so both held unmoved; at `f0268ad78`, 2026-10-09: both MOVED + * with their text byte-identical, by 21 * and by 9, from `a58626c88`'s `:325-327` and `:261-263`, for objectui#11839's * disabled-reason wrapper above them; `static-params.ts` is byte-identical). * Any @@ -4420,7 +4438,14 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * control `schema.editable` in `ObjectGrid.tsx`. It was declared ahead of its * reader on purpose (the BUILD objectui#11068 chose), and its describe carried * the `[EXPERIMENTAL — not enforced]` marker that said so. Re-measured at the - * pin this repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * pin this repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `ObjectGrid.tsx` changed across the hop from `f0268ad78` (+19/-11: + * objectui#11865 slice m, objectui `2063f7a96`: one import line, and the + * grouped pager's rows-per-page picker drawn with the shared `Select`) and + * `data-table.tsx` is byte-identical (`git diff --quiet`); the read MOVED + * `5519` -> `5520` with its line byte-identical, and the same method still + * finds 15 hit lines against 3 for the control. At `f0268ad78`, re-read there + * 2026-10-09: * `ObjectGrid.tsx` (+85/-27: objectui#11880 item 5, objectui#6152 rounds 7 and * 10, objectui#11817, objectui#11809, objectui#11689) and `data-table.tsx` * (+434/-30: objectui#11816, objectui#11690, objectui#11682) changed across the @@ -4444,7 +4469,7 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * `ab1879721`): the BUILD landed — objectui `154075ab1` (objectui#11068), inside * `89cad75d5570..ab1879721595` — and the grid reads the key. The same method * finds 0 hit lines at `89cad75d5570` and 15 at `ab1879721595`, at `2e818d0b51ec` and at this pin, against 3 for the - * control `schema.editable` in `ObjectGrid.tsx` at each: `ObjectGrid.tsx:5519` + * control `schema.editable` in `ObjectGrid.tsx` at each: `ObjectGrid.tsx:5520` * hands `schema.keyboardNavigation ?? inlineEditable` to the `data-table` it * renders, and `components/src/renderers/complex/data-table.tsx` acts on it. * So the marker is gone, as the member's record prescribed; see the member. @@ -4523,7 +4548,13 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * same members, and objectui's grid follows this declaration. * * ⚠️ That gap is closed at the pin this repo builds against (`.objectui-sha` - * = `f0268ad78`, re-read there 2026-10-09: + * = `47b1f0bb7`, re-read there 2026-10-09: + * `ObjectGrid.tsx` changed across the hop from `f0268ad78` (+19/-11: + * objectui#11865 slice m, objectui `2063f7a96`: one import line, and the + * grouped pager's rows-per-page picker drawn with the shared `Select`), every + * hunk above both reads (the last at `:6454-6465`), so both MOVED by 8 with + * their lines byte-identical: `6529-6530` -> `6537-6538`, `6546-6547` -> + * `6554-6555`. At `f0268ad78`, re-read there 2026-10-09: * `ObjectGrid.tsx` changed across the hop from `a58626c88` (+85/-27: * objectui#11880 item 5, objectui#6152 rounds 7 and 10, objectui#11817, * objectui#11809, objectui#11689), every hunk above both reads (the last at @@ -4543,7 +4574,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `6158e4c93`) resolves both members against the display locale before they * reach `DataEmptyState` — * `resolveInlineI18nLabel(authoredEmptyState?.title, displayLocale)` and the - * same for `message` (`ObjectGrid.tsx:6529-6530`, drawn at `:6546-6547`) — so + * same for `message` (`ObjectGrid.tsx:6537-6538`, drawn at `:6554-6555`) — so * both `I18nLabel` forms draw, and a locale map with no usable entry keeps * that member's default. At `89cad75d5570` the two still reached * `DataEmptyState` raw. @@ -4920,7 +4951,16 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `ComponentPropsMap` key. This record said to drop the marker in the change * that lands the BUILD at the pin, and it is dropped here. * - * Read at the pin this repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * Read at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `ObjectGrid.tsx` changed across the hop from `f0268ad78` (+19/-11: + * objectui#11865 slice m, objectui `2063f7a96`: one import line, and the + * grouped pager's rows-per-page picker drawn with the shared `Select`) and + * `data-table.tsx` is byte-identical (`git diff --quiet`), so the two grid + * anchors MOVED with their lines byte-identical — `5519` -> `5520`, `1824` -> + * `1825` — the five `data-table.tsx` anchors `1018`, `2607`, `3101`, `2257` + * and `2283` held unmoved, and no hunk lands in the arrow-key handling + * between `navigationTarget` and the end of `handleCellKeyDown`. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectGrid.tsx` (+85/-27: objectui#11880 item 5, objectui#6152 rounds 7 and * 10, objectui#11817, objectui#11809, objectui#11689) and `data-table.tsx` * (+434/-30: objectui#11816, objectui#11690, objectui#11682) changed across @@ -4944,9 +4984,9 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * and `data-table.tsx` were * byte-identical across the hop from `ab1879721`, where they were measured * 2026-10-03; the BUILD is objectui `154075ab1`, objectui#11068): - * `plugin-grid/src/ObjectGrid.tsx:5519` hands the data table + * `plugin-grid/src/ObjectGrid.tsx:5520` hands the data table * `keyboardNavigation: schema.keyboardNavigation ?? inlineEditable`, where - * `inlineEditable` (`:1824`) is the authored `editable` AND the viewer's + * `inlineEditable` (`:1825`) is the authored `editable` AND the viewer's * write verdict — so an absent key follows whether the grid RENDERS * editable, `true` turns the navigation on for a read-only grid, and `false` * turns it off on an editable one. `components/src/renderers/complex/data-table.tsx` @@ -5330,7 +5370,10 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ * same record for the metric tile. * * The key is LIVE at the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * every objectui file this record cites is byte-identical across the hop from * `a58626c88` (`git diff --quiet`), so every anchor held unmoved. At * `a58626c88`, re-read there 2026-10-06: @@ -5578,7 +5621,10 @@ const ObjectKanbanLaneSchema = lazySchema(() => strictObject({ * DESIGNER's spelling with * zero read points (#7973 class) — aliased to the `groupBy` the board reads. * `limit` (#16503) was measured later, at the pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectKanban.tsx` is byte-identical across the hop from `a58626c88` * (`git diff --quiet`), so the anchor did not move and was re-read in place. At * `a58626c88`, re-read there 2026-10-06: @@ -5685,7 +5731,11 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * Row cap (#16503 — the spec half of objectui#8172; decision batch #68, * 2026-09-07, option A: the contract declares the capability that already * ships, is documented and is in use). Measured at the objectui pin this - * repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved, and + * `plugin-kanban/src/types.ts` still declares no row-cap member. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectKanban.tsx`, `plugin-kanban/src/types.ts` (still no row-cap member), * `element-data-source.ts`, `ElementDataSourceGate.tsx` and * `plugin-kanban.mdx` are byte-identical to `a58626c88` (`git diff --quiet`), @@ -5950,7 +6000,13 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * quick-add control and no block a document can name offers one either). * * Measured at the objectui pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `KanbanBoardCore.tsx`, `ObjectKanban.tsx`, `KanbanImpl.tsx` and + * `plugin-kanban/src/index.tsx` are byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so `:78`, `:668`, `:681` and `345-346` + * did not move; `ObjectKanban.tsx` still names `quickAdd` and `onQuickAdd` 2 + * times each against 11 for `onCardClick`, and objectui still registers no + * `kanban-ui` block. At `f0268ad78`, re-read there 2026-10-09: * `KanbanBoardCore.tsx`, `ObjectKanban.tsx` and `KanbanImpl.tsx` are * byte-identical across the hop from `a58626c88` (`git diff --quiet`), so * `:78`, `:668` and `:681` did not move; `plugin-kanban/src/index.tsx` changed @@ -6142,6 +6198,9 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * element schemas). * * Measured at the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * every objectui file this record cites is byte-identical across the hop from * `a58626c88` (`git diff --quiet`), so every anchor held unmoved. At @@ -6354,6 +6413,9 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ * `object-kanban`'s above. * * Measured at the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * `ObjectCalendar.tsx` changed across the hop from `a58626c88` in one comment * line only, line for line (`:1250`, objectui `c0862c1cc` re-citing the @@ -7563,7 +7625,10 @@ const OBJECT_MAP_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ /** * `object-map` (objectui `plugin-map/src/ObjectMap.tsx` plus the registry shell * `plugin-map/src/index.tsx`, read at the pin this repo builds against — - * `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectMap.tsx` changed across the hop from `a58626c88` (+85/-4, * objectui#11819, objectui `fbaa79d19`: without WebGL2 the map lists its records * instead of crashing — a WebGL2 probe above every reader and a list fallback at @@ -7752,7 +7817,10 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ * Base query filter — the `ViewFilterRule` ARRAY form, the one filter * orthography every `filter` door in this map shares (ui#6206-B reaching the * `object-*` family: #15449, decision batch #55, option A). Measured at the - * pin this repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * pin this repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectMap.tsx` changed above all three anchors (+85/-4, objectui#11819, * objectui `fbaa79d19`: the WebGL2 probe and the list fallback), so each MOVED * down 38 with its text byte-identical, `:864` -> `:902`, `:958` -> `:996` and @@ -7820,7 +7888,10 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ * Marker order — the `SortItem` ARRAY form, the one sort orthography every * DECLARED `sort` door on this platform carries (objectui#8221, decision batch * #77, option B). Measured at the pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `ObjectMap.tsx` changed above both anchors (+85/-4, objectui#11819, objectui * `fbaa79d19`: the WebGL2 probe and the list fallback), so both MOVED down 38 * with their text byte-identical, `:959` -> `:997` and `:1038` -> `:1076`; and @@ -8010,7 +8081,10 @@ function objectGanttMarker() { /** * `object-gantt` (objectui `plugin-gantt/src/ObjectGantt.tsx` plus the registry * shell `plugin-gantt/src/index.tsx`, read at the pin this repo builds against - * — `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * — `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `ObjectGantt.tsx`, `record-source.ts` and `plugin-gantt/src/index.tsx` are + * byte-identical across the hop from `f0268ad78` (`git diff --quiet`), so every + * anchor in them held unmoved. At `f0268ad78`, re-read there 2026-10-09: * `ObjectGantt.tsx` and `record-source.ts` are byte-identical across the hop from * `a58626c88` (`git diff --quiet`), so every anchor in them held unmoved; * `plugin-gantt/src/index.tsx` changed in one line only, the `markers` input's @@ -8251,10 +8325,18 @@ export type ObjectGanttPropsParsed = z.infer; /** * The flat `TreeConfig` spellings `getTreeConfig` reads ahead of the `tree` * block (`ObjectTree.tsx:289-302`) and that `ObjectView` / `ListView` EMIT when - * they flatten `options.tree` (`ListView.tsx:3853-3872`, `case 'tree'`: the + * they flatten `options.tree` (`ListView.tsx:3913-3932`, `case 'tree'`: the * product carries these keys, the EFFECTIVE `filter` objectui#10250 added, and * NO `tree` key). Both halves re-READ at the - * pin this repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * pin this repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `ObjectTree.tsx` and `plugin-tree/src/index.tsx` are byte-identical to + * `f0268ad78` (`git diff --quiet`), so `289-302` and `240-261` did not move; + * `ListView.tsx` changed above and below its `case 'tree'` arm (+85/-26: + * objectui#11865 slice p, objectui `8f8f760fa`: the "Color by field" and + * rows-per-page pickers drawn with the shared `Select`), which MOVED + * byte-identical `3853-3872` -> `3913-3932`, `:3867` -> `:3927` with it, so the + * flat key set each reads or emits did not move. At `f0268ad78`, re-read there + * 2026-10-09: * `ObjectTree.tsx` and `plugin-tree/src/index.tsx` are byte-identical to * `a58626c88` (`git diff --quiet`), so `289-302` and `240-261` did not move; * `ListView.tsx` changed above and below its `case 'tree'` arm (+286/-46: @@ -8331,7 +8413,7 @@ export type ObjectGanttPropsParsed = z.infer; * * `titleField` is in the set although no `tree` block key is spelled that way: * `ListView`'s flatten resolves `treeCfg.titleField` into `labelField` before - * emitting (`ListView.tsx:3867`, `labelField: treeCfg.labelField || + * emitting (`ListView.tsx:3927`, `labelField: treeCfg.labelField || * treeCfg.titleField || 'name'`), so the author's intent is always the block's * `labelField`, and the prescription below says so. * @@ -8360,7 +8442,10 @@ const OBJECT_TREE_FLAT_CONFIG_GUIDANCE: readonly KeySetGuidance[] = [ /** * `object-tree` (objectui `plugin-tree/src/ObjectTree.tsx` plus the registry * shell `plugin-tree/src/index.tsx`, read at the pin this repo builds against - * — `.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * — `.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * every objectui file this record cites is byte-identical across the hop from * `a58626c88` (`git diff --quiet`), so every anchor held unmoved. At `a58626c88`, * re-read there 2026-10-06: @@ -8547,7 +8632,12 @@ export const ObjectTreePropsSchema = lazySchema(() => strictObject({ * holds for them because each registers through `ElementDataSourceGate`, * which lowers the spec binding onto `objectName` before the renderer sees * the node. `plugin-tree/src/index.tsx` does NOT: at the pin this repo - * builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * re-COUNTED by the same method, all five `src/index.tsx` shells are + * byte-identical across the hop from `f0268ad78` (`git diff --quiet`) and + * read 0 for the tree, 3 each for `plugin-calendar`, `plugin-gantt` and + * `plugin-grid`, and 4 for `plugin-map`. At `f0268ad78`, re-read there + * 2026-10-09: * re-COUNTED by the same method, 0 for the tree and `plugin-map`'s 4, both * shells byte-identical across the hop from `a58626c88` (`git diff --quiet`), * and 3 each for `plugin-calendar`, `plugin-gantt` and `plugin-grid`, whose @@ -8935,7 +9025,10 @@ function objectTimelineItemsFitVariant() { * `object-timeline` (objectui `plugin-timeline/src/ObjectTimeline.tsx`, the * presentational `plugin-timeline/src/renderer.tsx` it composes into, and the * registry shell `plugin-timeline/src/index.tsx` — all read at the pin this - * repo builds against (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * repo builds against (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At + * `f0268ad78`, re-read there 2026-10-09: * `renderer.tsx` is byte-identical to `a58626c88` (`git diff --quiet`), so its * three anchors held unmoved; `index.tsx` rewrote two description constants in * place, line for line (`:432` and `:438`, objectui `c0862c1cc`), so `:334` and @@ -9168,6 +9261,9 @@ export const ObjectTimelinePropsSchema = lazySchema(() => strictObject({ * first publishes a refusal for a key the renderer honours. * * Read points at the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * `record-source.ts` and `SchemaRenderer.tsx` are byte-identical across the hop * from `a58626c88` (`git diff --quiet`), so the prop channel above still @@ -9476,6 +9572,9 @@ export const ComponentPropsMap = { // set from this map's keys), on the three-part evidence that vocabulary's // string-arm ledger asks of a type admitted without an enum member — all // measured at the pin this repo builds against (`.objectui-sha` = + // `47b1f0bb7`, re-read there 2026-10-09: + // every objectui file this record cites is byte-identical across the hop from + // `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At // `f0268ad78`, re-read there 2026-10-09: // `public-blocks.ts` is byte-identical across the hop from `a58626c88` // (`git diff --quiet`), so `:117-118` did not move; `data-list.tsx` changed @@ -9562,6 +9661,9 @@ export const ComponentPropsMap = { // authority for map and gantt while tree's rung-1 `data` read stayed // undeclared on every face. Key sets measured from the renderers' read // points at the pin this repo builds against (`.objectui-sha` = + // `47b1f0bb7`, re-read there 2026-10-09: + // every objectui file this record cites is byte-identical across the hop from + // `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At // `f0268ad78`, re-read there 2026-10-09: // `ObjectGantt.tsx` and `ObjectTree.tsx` are byte-identical to `a58626c88` // (`git diff --quiet`); `ObjectMap.tsx` (+85/-4) changed for objectui#11819 @@ -9636,6 +9738,9 @@ export const ComponentPropsMap = { // it — so `object-timeline` was unjudged in both directions, a real key and // a typo riding through alike. Key set measured from the renderer's read // points at the pin this repo builds against (`.objectui-sha` = + // `47b1f0bb7`, re-read there 2026-10-09: + // every objectui file this record cites is byte-identical across the hop from + // `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At // `f0268ad78`, re-read there 2026-10-09: // `renderer.tsx` is byte-identical to `a58626c88` (`git diff --quiet`); // `index.tsx` rewrote two input descriptions in place and `ObjectTimeline.tsx` diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 6c137e0561a..33eac541ec3 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -283,6 +283,9 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ * and this docblock and the `describe` beneath it both said so. * * Measured at the pin this repo builds against (`.objectui-sha` = + * `47b1f0bb7`, re-read there 2026-10-09: + * every objectui file this record cites is byte-identical across the hop from + * `f0268ad78` (`git diff --quiet`), so every anchor held unmoved. At * `f0268ad78`, re-read there 2026-10-09: * `dataset-format.ts` is byte-identical to `a58626c88` (`git diff --quiet`), so * `formatMeasureDate` `:240-274`, its datetime arm `:270`-`:272` and its call diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 26426fc83fd..12d5a7f23a4 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3294,7 +3294,13 @@ const FormFieldBaseSchema = lazySchema(() => { * inside the `53ded82bf7...87af769e9` range, so the widest-tier-only * under-span this block used to record (#17328: one cell of two at * 720px) no longer reproduces at the pin this repo builds against - * (`.objectui-sha` = `f0268ad78`, re-read there 2026-10-09: + * (`.objectui-sha` = `47b1f0bb7`, re-read there 2026-10-09: + * `autoLayout.ts`, `fields`' `field-type-alias.ts` and `form.tsx` are + * byte-identical to `f0268ad78` (`git diff --quiet`), so `resolveColSpan` + * `:154`, `WIDE_FIELD_TYPES` `:58-69`, the `repeater` -> `field:grid` + * mapping, `spanLadderFor` `:274-301` and its one call site `:3202` held + * unmoved, so it still emits the ladder. At `f0268ad78`, re-read there + * 2026-10-09: * `autoLayout.ts` and `fields`' `field-type-alias.ts` are byte-identical to * `a58626c88` (`git diff --quiet`), so `resolveColSpan` `:154`, * `WIDE_FIELD_TYPES` `:58-69` and the `repeater` -> `field:grid` mapping held @@ -3367,7 +3373,7 @@ const FormFieldBaseSchema = lazySchema(() => { * had changed only in its registration's input list, objectui#9910's * `children` slot; at `62597c588` it was byte-identical to `87af769e9`). */ - span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `f0268ad78485`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest."), + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7174`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest."), /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From e7c00d42284f0c7865c5bc1597bf855a2b1e8a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:23:32 +0000 Subject: [PATCH 6/8] docs(references): regenerate ui/view.mdx for the FormField.span describe's new pin sha Written by pnpm --filter @objectstack/spec check:generated --fix, which regenerated only the one artifact it proved stale. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ad52bcbc453..63d5f3eda96 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -213,7 +213,7 @@ Column footer summary configuration | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `f0268ad78485`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7174`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -343,7 +343,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `f0268ad78485`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `47b1f0bb7174`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at that same pin the renderer emits one clamped col-span class per multi-column tier (`@md:col-span-2 @2xl:col-span-3` for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | From d4357cac5c3a6dda4147734c3eb140618eeb2741 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:38:25 +0000 Subject: [PATCH 7/8] chore(spec): regenerate spec-changes.json and the upgrade guide on the merged tree Main's 4e9fe9ff6 (PROTOCOL_VERSION 17 -> 18, #22215) began rendering the 18.* semantic entries into both artifacts. Six of them carry this branch's re-measured objectui pin readings, so the merged tree's copies were stale. Generators only: pnpm --filter @objectstack/spec gen:spec-changes and gen:upgrade-guide. No source edit. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 12 ++++++------ packages/spec/spec-changes.json | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index f72946075bb..ccbd1093a61 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -1042,7 +1042,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` = `f0268ad784854568aa58a2aa791f6a7502259186` — 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 8234 tracked files (0 across 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 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count). + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — 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 8281 tracked files (0 across 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 and to 17980 and 7523 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. @@ -1054,7 +1054,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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. + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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. @@ -1072,7 +1072,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` / `timeout` and LoggingConfig `buffer.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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 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). + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 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//`; 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. @@ -1351,7 +1351,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (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. + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (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. @@ -1363,7 +1363,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 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: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (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). + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 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: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (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. @@ -1372,7 +1372,7 @@ This is a RUNTIME registration API, not stored metadata, so — like `hook-regis - 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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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). + - 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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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` / TenantSecurityPolicy `accessControl.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. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index c8f42c57087..d76a23107a2 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2351,7 +2351,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — 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 8234 tracked files (0 across 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 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — 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 8281 tracked files (0 across 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 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "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)", @@ -2379,7 +2379,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -2421,7 +2421,7 @@ "replacement": "`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", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 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)." }, { "surface": "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", @@ -3072,7 +3072,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (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." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (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." }, { "surface": "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)", @@ -3100,7 +3100,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 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: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 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: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (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)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -3121,7 +3121,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", @@ -5947,7 +5947,7 @@ "replacement": "intervalMs, timeoutMs and debounceDelayMs — rename each key; all three values (milliseconds) and their 30000 / 5000 / 1000 defaults are unchanged", "migrationId": "kernel-health-check-and-hot-reload-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — 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 8234 tracked files (0 across 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 and to 17956 and 7522 at this pin (git grep -o -F, the method that reproduces every earlier count)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — 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 8281 tracked files (0 across 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 and to 17980 and 7523 at this pin (git grep -o -F, the method that reproduces every earlier count)." }, { "surface": "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)", @@ -5975,7 +5975,7 @@ "replacement": "resourceLimits.timeoutMs — rename the key; the value (milliseconds) is unchanged", "migrationId": "kernel-runtime-config-timeout-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells resourceLimits.timeout 0 times across 8234 tracked files, against lit controls timeout 1658, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells resourceLimits.timeout 0 times across 8281 tracked files, against lit controls timeout 1674, RuntimeConfig 337 and resourceLimits 2 on the same corpus (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." }, { "surface": "the three startup-orchestration durations whose name carried no unit: StartupOptions.timeout, PluginStartupResult.duration and StartupOrchestrationResult.totalDuration (kernel/startup-orchestrator.zod.ts)", @@ -6017,7 +6017,7 @@ "replacement": "`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", "migrationId": "logging-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8234 tracked files, against lit controls `useState` 2622 and `timeout` 1658 on the same corpus (all four 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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells `flushInterval` 0 times, `initialDelay` 0, `HttpDestinationConfig` 0 and `LoggingConfig` 0 across its 8281 tracked files, against lit controls `useState` 2630 and `timeout` 1674 on the same corpus (all four 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)." }, { "surface": "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", @@ -6668,7 +6668,7 @@ "replacement": "summary.maxAgeSeconds, errorBudget.burnRateWindows[].durationSeconds, intervalSeconds, collectionIntervalSeconds and retention.durationSeconds — rename each key; every value is unchanged", "migrationId": "system-metrics-jsdoc-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8234 tracked files at that sha, against lit controls window 4430, timeout 1658, period 249, interval 213 and metrics 404 on that same corpus and sha (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." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a`, re-read from this tree — spells all six metrics def names and both distinctive keys 0 times across 8281 tracked files at that sha, against lit controls window 4449, timeout 1674, period 249, interval 213 and metrics 455 on that same corpus and sha (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." }, { "surface": "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)", @@ -6696,7 +6696,7 @@ "replacement": "timeoutMs, exportTimeoutMs, scheduledDelayMs and exportIntervalMs — rename each key; all four values (milliseconds) and their 10000 / 30000 / 5000 / 5000 defaults are unchanged", "migrationId": "system-tracing-otel-exporter-durations-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8234 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: 17956 hits for the bare token objectstack, and 7522 for the package specifier @objectstack/spec (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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — names none of it: all 37 exports of tracing.zod.ts and each of the four key names occur 0 times across the 8281 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: 17980 hits for the bare token objectstack, and 7523 for the package specifier @objectstack/spec (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)." }, { "surface": "Span.duration, the emitted trace-span length whose name carried no unit (system/tracing.zod.ts)", @@ -6717,7 +6717,7 @@ "replacement": "`performance.schemaCacheTtlSeconds` (default 3600) — rename the key; the value (seconds) is unchanged", "migrationId": "tenant-schema-cache-ttl-unit-in-key", "toMajor": 18, - "rationale": "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` = `f0268ad784854568aa58a2aa791f6a7502259186` — spells it 0 times across 8234 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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)." + "rationale": "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` = `47b1f0bb71748a7d16f36edecc50059367d2e35a` — spells it 0 times across 8281 tracked files, against lit controls `TTL` 184 and `tenant` 1338 on the same corpus (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)." }, { "surface": "DatabaseLevelIsolationStrategy `connectionPool.idleTimeout` / TenantSecurityPolicy `accessControl.sessionTimeout` (system/tenant.zod.ts)", From a252adcef18e6e99d9b51f13af2d746bf6de6d07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:58:16 +0000 Subject: [PATCH 8/8] chore(spec): regenerate spec-changes.json and the upgrade guide on the merged tree (main f782f1764) The os-regen text merge kept this branch's stale copy of main's flow-value-slot-template-dialect-refused replacement text (list[0]); main's source entry reads items[0] since 40a6ee50a (#22524). Generators only: pnpm --filter @objectstack/spec gen:spec-changes and gen:upgrade-guide. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude --- docs/protocol-upgrade-guide.md | 2 +- packages/spec/spec-changes.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index ccbd1093a61..ca07a3e18d2 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -976,7 +976,7 @@ AUTHOR-REACHABLE SURFACES: a saved report's `query.filter` (`sys_saved_report`) - **`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` → 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, list[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). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal +- **`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` → 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). 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. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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. 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. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index d76a23107a2..252fc50ce15 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -2215,7 +2215,7 @@ }, { "surface": "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", - "replacement": "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, list[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). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "replacement": "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). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "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. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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." @@ -5811,7 +5811,7 @@ }, { "surface": "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", - "replacement": "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, list[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). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", + "replacement": "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). A string with no token is the literal text it spells, and braces meant literally are a CEL string literal", "migrationId": "flow-value-slot-template-dialect-refused", "toMajor": 18, "rationale": "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. Two spellings are kept with their old meaning, because CEL cannot write them 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) and the run-user paths beginning $User. (the flow CEL scope binds no user). 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."