diff --git a/.changeset/govern-remaining-nine-metadata-types.md b/.changeset/govern-remaining-nine-metadata-types.md new file mode 100644 index 0000000000..38781d6d17 --- /dev/null +++ b/.changeset/govern-remaining-nine-metadata-types.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': patch +--- + +Liveness coverage is complete: the nine remaining registered metadata types are +governed (#4488) — `app`, `book`, `doc`, `email_template`, `job`, `mapping`, +`seed`, `translation`, `validation` — and `PENDING_GOVERNANCE` is empty. Every +type in the metadata-type registry now has a ledger with per-property verdicts, +evidence, and a `verifiedAt` stamp. + +Spec: + +- Nine new ledgers under `packages/spec/liveness/` (≈150 verdicts). Highlights: + the ENTIRE `email_template` authoring surface is dead (nothing materializes + metadata items into the `sys_email_template` rows `sendTemplate` reads — an + admin editing the password-reset mail in Studio changes nothing; #4509); + `app.areas[].visible` / `areas[].requiredPermissions` are fail-open dead + gates (item-level siblings ARE enforced); `translation.validationMessages` + is read by nothing while #3778's own migration table steers authors into it; + `job`/`validation` have runtime-authoring doors disconnected from their + execution points (#4509). `doc` and `seed` are fully live. +- `check-liveness.mts`: the walker now sees through `z.preprocess` pipes + (takes the OUT side when the IN side is a transform) — `translation`'s + registered schema was unwalkable before this. +- `liveness/README.md`: the per-type count table's method is now decided and + recorded (it mirrors `check-liveness.mts --json` `byStatus`, the number CI + enforces); all rows regenerated from one run, and the two-generations-stale + `webhook` row rewritten to the post-#3489/#3494 state. + +CLI: + +- `lint-liveness-properties` registers the six newly governed types that carry + `authorWarn` entries (`apps`, `books`, `jobs`, `emailTemplates`, `mappings`, + `translations`), so authors hear about the misleading keys at compile time. diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index fdc74c542b..4997ccaa25 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -250,4 +250,136 @@ describe('lintLivenessProperties', () => { }); expect(findings).toEqual([]); }); + + // ── #4488 — the nine remaining types, governed. Pins run against the REAL + // ledgers, one per finding class the audit surfaced. + + // The app ledger's most important entries: area-level gating keys that FAIL + // OPEN (nothing evaluates them, so a "hidden"/"gated" area shows for + // everyone), on the surface whose item-level siblings ARE enforced. + it('warns on the fail-open area gates and dead homePageId (#4488)', () => { + const findings = lintLivenessProperties({ + apps: [{ + name: 'crm', + label: 'CRM', + homePageId: 'nav_pipeline', + areas: [{ + id: 'area_sales', + label: 'Sales', + order: 2, + visible: "'sales' in current_user.positions", + requiredPermissions: ['crm.access'], + navigation: [], + }], + }], + }); + const msgs = paths(findings); + expect(msgs.some((m) => m.includes('homePageId'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.order'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.visible'))).toBe(true); + expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(true); + // The gating hints must point at the enforced alternative (per-item gates), + // or the warning just relocates the author's confusion. + const perms = findings.find((f) => f.message.includes('areas.requiredPermissions')); + expect(perms!.hint).toMatch(/per item|Per-item/i); + }); + + // email_template: the WHOLE authoring surface is disconnected from + // sendTemplate (webhook shape) — one per-artifact warn carried on `name`. + it('warns once per email_template artifact via name (#4488)', () => { + const findings = lintLivenessProperties({ + emailTemplates: [{ + name: 'crm.welcome', + label: 'Welcome', + subject: 'Hi {{user.name}}', + bodyHtml: '

Welcome

', + }], + }); + const hit = findings.find((f) => f.message.includes('`name`')); + expect(hit).toBeDefined(); + expect(hit!.hint).toMatch(/sys_email_template/); + }); + + // translation.validationMessages: pointed at by #3778's own migration table, + // read by nothing — the hint must say what actually renders (rule.message). + it('warns on translation.validationMessages (#4488)', () => { + const findings = lintLivenessProperties({ + translations: [{ + name: 'zh_cn', + locale: 'zh-CN', + validationMessages: { discount_limit: '折扣不能超过40%' }, + }], + }); + const hit = findings.find((f) => f.message.includes('validationMessages')); + expect(hit).toBeDefined(); + expect(hit!.hint).toMatch(/message/); + }); + + // book: both inline translations maps are dead (the doc-level map two files + // over works, which is what makes these read alive); job.id and + // mapping.extractQuery are the other flat dead keys. + it('warns on book/job/mapping dead keys (#4488)', () => { + const findings = lintLivenessProperties({ + books: [{ + name: 'crm_guide', + label: 'CRM Guide', + translations: { 'zh-CN': { label: 'CRM 指南' } }, + groups: [{ key: 'basics', label: 'Basics', translations: { 'zh-CN': { label: '基础' } } }], + }], + jobs: [{ + name: 'nightly_sync', + id: 'job_nightly', + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: 'syncAll', + }], + mappings: [{ + name: 'csv_import_contacts', + targetObject: 'contact', + fieldMapping: [], + extractQuery: { object: 'contact', fields: ['name'] }, + }], + }); + const msgs = paths(findings); + expect(msgs.some((m) => m.includes('`translations`'))).toBe(true); + expect(msgs.some((m) => m.includes('groups.translations'))).toBe(true); + expect(msgs.some((m) => m.includes('`id`'))).toBe(true); + expect(msgs.some((m) => m.includes('extractQuery'))).toBe(true); + }); + + // The unwarnable-default rule, negative direction: errorPolicy/batchSize + // (mapping) and includeAll/placement (app selectors) materialize from schema + // defaults on every compiled artifact, so their dead entries carry + // _authorWarnSkipped instead of authorWarn — a compiled stack that only has + // defaults must stay silent. + it('stays silent on schema-default values and live-only artifacts (#4488)', () => { + const findings = lintLivenessProperties({ + mappings: [{ + name: 'api_sync_orders', + targetObject: 'order', + fieldMapping: [{ source: 'Total', target: 'total' }], + mode: 'upsert', + upsertKey: ['external_ref'], + // materialized defaults — must NOT warn: + sourceFormat: 'csv', + errorPolicy: 'skip', + batchSize: 1000, + }], + apps: [{ + name: 'sales', + label: 'Sales', + contextSelectors: [{ + id: 'active_region', + label: 'Region', + optionsSource: { endpoint: '/api/v1/regions', valueKey: 'id', labelKey: 'name' }, + // materialized defaults — must NOT warn: + includeAll: true, + allValue: '', + persist: 'query', + placement: 'sidebar_header', + }], + }], + seeds: [], + }); + expect(findings).toEqual([]); + }); }); diff --git a/packages/cli/src/utils/lint-liveness-properties.ts b/packages/cli/src/utils/lint-liveness-properties.ts index c1c69d2b64..f6091f263f 100644 --- a/packages/cli/src/utils/lint-liveness-properties.ts +++ b/packages/cli/src/utils/lint-liveness-properties.ts @@ -188,6 +188,15 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ // collection registered or its ledger warns nobody, which would leave the // ledger correct and silent: the exact shape this lint exists to prevent. { type: 'datasource', key: 'datasources' }, + // #4488 — the six newly governed types that carry `authorWarn` entries. + // (doc / seed / validation are governed too but warn on nothing today, so + // they are not listed; add them here the day one of their entries warns.) + { type: 'app', key: 'apps' }, + { type: 'book', key: 'books' }, + { type: 'job', key: 'jobs' }, + { type: 'email_template', key: 'emailTemplates' }, + { type: 'mapping', key: 'mappings' }, + { type: 'translation', key: 'translations' }, ]; /** diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index ff6f458b51..c7a751e0c8 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -463,65 +463,72 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type RecordDetailView had been gating the History tab on it the whole time (#2707). 4. Add the type to `GOVERNED`; confirm the gate is green. -## Current state — 17 governed types - -Counts include drilled `children` entries; regenerate with the snippet below rather -than hand-editing (this table drifted badly once — field was listed 34/39 while the -ledger actually said 54/6). +## Current state — 27 governed types (complete registry coverage) + +**The counting method for this table is the gate's own report** — +`check-liveness.mts --json`, `types..byStatus` — decided in #4488 after +two methods spent a release disagreeing. The alternative (a python snippet that +counted ledger JSON rows) systematically undercounted: it missed statuses +resolved from `describe()` markers, the ADR-0010 framework overlay fields the +gate auto-classifies `live`, and `childrenDefault` fan-outs — and a mechanical +rewrite with it produced two regressions while #4487 was being written. The +gate's numbers are what CI actually enforces, so they are what the table +mirrors. Two corollaries: counts are at the gate's **one-level walk +granularity** (a Notes cell may annotate finer detail, e.g. `query`'s +marker-experimental search sub-keys, without the counts reflecting it), and the +count columns are **never hand-edited** — regenerate: ```bash -python3 - <<'EOF' -import json, glob, os -from collections import Counter -for f in sorted(glob.glob('packages/spec/liveness/*.json')): - d = json.load(open(f)); c = Counter() - def walk(ps): - for v in ps.values(): - if 'status' in v: c[v['status']] += 1 - walk(v.get('children') or {}) - walk(d.get('props', {})) - print(os.path.basename(f)[:-5], dict(c)) -EOF +cd packages/spec && npx tsx scripts/liveness/check-liveness.mts --json | python3 -c " +import json,sys +r = json.load(sys.stdin) +for t, v in r['types'].items(): + b = v['byStatus'] + print(f\"| {t} | {b.get('live',0)} | {b.get('experimental',0)} | {b.get('dead',0)} | {b.get('planned',0)} |\")" ``` | Type | live | exp | dead | planned | Notes | |---|---|---|---|---|---| -| object | 40 | – | 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | -| field | 55 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | -| flow | 26 | – | 5 | – | dead count = 4 tombstone entries + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges); remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | +| object | 49 | – | 0 | 1 | aspirational tier (versioning/softDelete/search/recordName/keyPrefix) + tags/active/abstract REMOVED (#2377) — tombstoned in UNKNOWN_KEY_GUIDANCE; `enable.trash`/`mru` REMOVED (#2377 close-out) — tombstoned in the now-`.strict()` ObjectCapabilities; `isSystem` + `enable.searchable` CORRECTED to live (#2377 — sharing default-model + global-search opt-out; 2026-06 audit missed both readers); `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) | +| field | 59 | – | 0 | – | healthy — full dead set (vectorConfig/fileAttachmentConfig/dependencies, then referenceFilters/columnName/index) REMOVED (#2377); columnName also dropped the ADR-0062 D7 lint + StorageNameMapping column helpers | +| flow | 34 | – | 5 | – | dead count = 4 tombstone entries + the kept docs field: `active`/`template`/nodes.`outputSchema`/errorHandling.`fallbackNodeId` REMOVED 2026-07-30 (#3896 close-out sweep — `active: false` never stopped a flow, `status` is the enforced lifecycle; faults route via per-node fault edges); remaining dead = `description`, KEPT deliberately: docs-shaped, exempt from enforce-or-remove | | action | 34 | 0 | 2 | – | `type:'form'` CORRECTED to live (objectui ActionRunner.executeForm, #2377); dead `timeout` REMOVED (#2377); `disabled` live since objectui#2863; `undoable` CORRECTED to live (#3714); `shortcut` + `bulkEnabled` REMOVED 2026-07-30 (#3896 close-out sweep — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | | hook | 11 | – | 2 | – | model-healthy; label/description dead but KEPT deliberately (2026-07-30 sweep) — docs-shaped annotation fields, exempt from enforce-or-remove | -| permission | 29 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | -| position | 4 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | -| agent | 13 | 4 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | -| tool | 5 | 1 | 0 | – | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | -| skill | 8 | – | 1 | – | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | -| dataset | 19 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | +| permission | 38 | – | 4 | – | CRUD/FLS/RLS live; dead `contextVariables` REMOVED (ADR-0105 D11 — RLS resolves only the `current_user.*` built-ins plus runtime-staged `rlsMembership` sets). 2026-07-30 security-subset re-verification (all 33 entries `verifiedAt`-stamped): `rowLevelSecurity.enabled` was live-with-wrong-evidence and UNREAD — a disabled policy kept contributing its OR-branch grant; ENFORCED same day in rls-compiler (`getApplicablePolicies`), the `positions` ADR-0049 resolution repeated. `rowLevelSecurity.priority` CORRECTED to dead+authorWarn — semantically void under OR-combination (no conflict exists to order), a REMOVE candidate. `rls.label`/`description`/`tags` CORRECTED to dead (benign display, no consumer in either repo). `tabPermissions` was UNDERSTATED ("only hidden read" → the rank merge reads all four values; me-apps dogfood test exercises it). `allowExport` re-verified TRUE end-to-end (server-side 403 gate, not just the /me projection) | +| position | 12 | – | – | – | (role's ADR-0090 successor) fully live; all 4 `verifiedAt`-stamped 2026-07-30 | +| agent | 21 | 4 | 1 | – | dead `tenantId` + `planning.strategy`/`allowReplan` REMOVED (#2377); autonomy tier experimental; `knowledge` REMOVED 2026-07-30 (#3896 close-out sweep — declaring sources never scoped retrieval; AIKnowledgeSchema removed with it, the topics→sources rename absorbed pre-release) | +| tool | 13 | 1 | 0 | – | the inert authoring surface is now REMOVED, not merely marked: `category`/`permissions`/`active`/`builtIn` retired 2026-07-30 (#3896 close-out) after `requiresConfirmation` set the precedent (#3715, ADR-0033 §2). `permissions` promised an invocation gate nothing enforced and `active:false` withdrew nothing — false compliance, same shape as rls.enabled. The `.strict()` ToolSchema rejects each retired key with its prescription; the `tool-inert-authoring-keys-removed` conversion strips them from authored sources | +| skill | 16 | – | 1 | – | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | +| dataset | 27 | – | 0 | – | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | 16 | – | – | 1 | fully live + one planned | -| view | 70 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | +| view | 79 | 0 | 4 | – | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification; level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) noted on parents — one drill level only | -| report | 13 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | -| dashboard | 10 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | -| query | 16 | 7 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The 7 experimental resolve from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | +| report | 21 | 0 | 0 | – | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) | +| dashboard | 18 | 0 | 2 | – | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) | +| query | 16 | 1 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | | datasource | 23 | – | 20 | – | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) | -| webhook | 0 | 1 | 16 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema` (#3461/#3462). The ENTIRE authoring surface is dead: nothing materializes an authored `webhooks:` entry into a `sys_webhook` dispatcher row (#3461, enforce-or-remove pending). `url` carries the single per-webhook `authorWarn` (one no-op heads-up per artifact, not per-prop); `authentication` experimental (HMAC-`secret`-only); `isActive` unmarked (default(true)). Notes cite the sys_webhook column map as the future materializer's mapping table | +| webhook | 11 | 0 | 0 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | +| app | 45 | – | 14 | – | seeded 2026-08-01 (#4488). Dead 14 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (the landing IS the first nav item; root landing follows `isDefault` routing) + the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` (nothing evaluates them, while the per-ITEM siblings are enforced server- and client-side — the audit's most important app finding, both authorWarn'd) + `areas.order`/`description` + selector `includeAll` (deliberately ignored: selectors are mandatory-scope; an "All" would leak system metadata) and `placement`. Nav walk covers the union's `object` variant; other variants hand-verified live except the `actionDef` dispatch gap (renders, but no shipped shell passes `onAction`) — #4509 | +| book | 13 | – | 2 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | +| doc | 7 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type | +| email_template | 8 | – | 13 | – | seeded 2026-08-01 (#4488). **Every authorable property is dead** — the 8 live are the ADR-0010 framework overlay fields the gate auto-classifies. Webhook's OLD shape: `sendTemplate` reads `sys_email_template` ROWS, whose only writers are the built-in auth templates + code-constructed plugin options; every authoring door (stack `emailTemplates:`, `*.email-template.ts`, Studio metadata-admin, PUT /meta) lands items nothing reads back. An admin who "fixes" the password-reset mail in Studio changes nothing — false compliance on AUTH mail. One per-artifact authorWarn on `name`; `upsertTemplate`'s field map is the future bridge's mapping table — #4509 | +| job | 6 | – | 3 | – | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). Type-level gap recorded: `allowRuntimeCreate: true` but no path schedules a runtime-authored job item — #4509 | +| mapping | 7 | – | 3 | – | seeded 2026-08-01 (#4488). The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. Dead 3 = `extractQuery` (authorWarn — "for export only" promises an export path that does not exist) + `errorPolicy`/`batchSize` (dead but UNWARNABLE: their schema defaults materialize at compile, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule) | +| seed | 5 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | +| translation | 10 | – | 1 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | +| validation | 8 | – | 3 | – | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys (transitions/initialStates/regex/schema/when/then/…) verified via the evaluator's own tests. Type-level gap: a STANDALONE `validation` item binds to no object and reaches no write path — #4509 | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every -misleading entry carries `authorWarn` so authors hear about it at compile time. -Not yet governed: app, job, translation, email_template, doc, book, validation, -seed — now enumerated in `PENDING_GOVERNANCE` in `check-liveness.mts` and -enforced (#4487; worklist #4488). This paragraph used to be the only record of -the rollout gap, which is precisely why the gap survived: prose in a README -cannot fail a build, so a registered type nobody had ever audited looked -identical to one audited and found clean. The gate now compares `GOVERNED` -against the metadata-type registry and refuses a type in neither list. - -⚠️ **Several count columns above are stale**, and #4487 deliberately did not -bulk-correct them. Two methods disagree — the python snippet below counts ledger -JSON entries, while the gate's `--json` report also resolves `describe()` markers -and drills `children` differently — and the `query` and `webhook` rows are -annotated beyond either (see their notes). A mechanical rewrite with the wrong -method silently flattens those annotations; that was attempted while writing -#4487 and produced two regressions before being caught. Fixing this properly -means deciding which method the table means and recording it here. Tracked in -#4488. +misleading entry carries `authorWarn` so authors hear about it at compile time +(governed types with warn entries must also be registered in the CLI lint's +`TYPE_COLLECTIONS` — see lint-liveness-properties.ts). + +**Coverage is complete as of #4488**: every type in the metadata-type registry +is governed, and `PENDING_GOVERNANCE` in `check-liveness.mts` is empty. The map +itself stays, because the ratchet is the point — registering a new type without +a ledger fails CI with instructions to govern it or record the debt (reason + +issue number). The paragraph that used to sit here, listing nine ungoverned +types as prose, is precisely how the gap survived for a year: prose cannot fail +a build. Now the gate compares `GOVERNED` against the registry in both +directions (an ungoverned registered type fails; so does a stale pending row +whose debt is already paid). diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json new file mode 100644 index 0000000000..81452f5fdc --- /dev/null +++ b/packages/spec/liveness/app.json @@ -0,0 +1,316 @@ +{ + "type": "app", + "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:1796-1847 — the SERVER-side authority for app/nav permission + capability gating and ADR-0045 hidden-app visibility), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. ONE GAP found there, recorded not hidden: an `action` item renders and gates like any other, but its click dispatches through a host-supplied `onAction` prop that NO shipped shell passes — `actionDef.actionName` currently reaches no dispatcher (#4509). Also note filterAppForUser strips only the TOP-LEVEL `navigation` tree; `areas` trees rely on the client-side per-item gates. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:478", + "note": "routing identity (`/apps/`) and the translation-bundle key (`apps..*`); objectui RootLandingRedirect routes by it." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:481; objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:327-355 (switcher/header)", + "note": "localized on serve by translateApp, rendered by the app switcher and shell header." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:333; framework packages/spec/src/system/i18n-resolver.ts:482", + "note": "rendered under the active app's title; localized by translateApp." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:327, :355", + "note": "App Launcher / switcher icon." + }, + "branding": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:185-186 (logo, primaryColor); objectui packages/app-shell/src/layout/ConsoleLayout.tsx:172-173 (accentColor, favicon)", + "note": "all four children live in the shell chrome; `accentColor` and the `separator`/`badgeVariant` nav keys were themselves inverse-drift fixes (declared to match an existing objectui read, liveness audit #1878/#1891/#1894)." + }, + "active": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178", + "note": "`active: false` delists the app from the switcher. Deliberately does NOT disable routing — the active-app lookup spans all apps so a direct /apps/ URL keeps rendering (AppSidebar:179-180 comment). Weaker than the name implies, but a real consumer." + }, + "isDefault": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/components/RootLandingRedirect.tsx:46", + "note": "ROUTING semantics: the root landing redirects to the app marked default (it was once a display-only badge — the file says so)." + }, + "hidden": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1811; objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178", + "note": "SERVER-enforced (ADR-0045): a hidden app is served only to builders (studio/setup access) for direct-URL preview; the client switcher filter is a listing courtesy on top." + }, + "navigation": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:125", + "note": "item identity: render keys, pin/reorder persistence, i18n nav key (`apps..navigation..label`)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:456; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:284-316 (resolveNavItemLabel)", + "note": "rendered everywhere; translateApp swaps in the per-locale label by node id." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:961", + "note": "every variant branch resolves and renders it." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:1154, :913", + "note": "low-first stable sort at the top level and inside each group." + }, + "badge": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:983-985" + }, + "badgeVariant": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:983, :1024", + "note": "declared to match this exact read (inverse-drift fix, audit #1878/#1891/#1894)." + }, + "visible": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:891 (item gate); objectui packages/app-shell/src/layout/AppSidebar.tsx:236 (CEL evaluation via ExpressionProvider)", + "note": "the CEL visibility gate — enforced per item. Note the contrast with `areas[].visible`, which is NOT." + }, + "requiredPermissions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1830; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:894", + "note": "enforced in BOTH layers: the server strips unsatisfied entries from the top-level navigation tree before serving, and the client re-gates per item." + }, + "requiresObject": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:899-901", + "note": "runtime-capability gate against the SchemaRegistry (client-side; the server gates only requiresService)." + }, + "requiresService": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1832; objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:900-902", + "note": "ADR-0057 D10 capability gate, server + client." + }, + "type": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:905-960 (branch dispatch), :397-471 (href resolution per variant)", + "note": "the discriminant. Variant payload keys outside this walk are covered in the type note — all live except the `actionDef` dispatch gap." + }, + "objectName": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:296, :397-418" + }, + "viewName": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:294-296, :418", + "note": "target precedence recordId → filters → viewName; also keys the view-label i18n lookup." + }, + "recordId": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:397-406, :598", + "note": "direct-to-record deep link; {current_user_id}/{current_org_id} and context-selector {} template vars substituted by the shell." + }, + "recordMode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:406" + }, + "filters": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:416-418", + "note": "serialized as filter[]= params onto the bare /data surface (objectui ADR-0055); exclusivity with recordId/viewName is parse-rejected (objectNavTargetExclusivity)." + }, + "children": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/layout/src/NavigationRenderer.tsx:910-913", + "note": "recursive render with per-group order sort; the server's filterNav collapses groups emptied by permission stripping (rest-server.ts:1837)." + } + }, + "note": "Walked children are the `object` variant + base keys (union-first rule) — see the type note for the other variants' hand-verified payload keys." + }, + "areas": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:197-210", + "note": "area-switcher identity and active-area state key." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:456" + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:448" + }, + "order": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — no renderer sorts areas (AppSidebar and AppSchemaRenderer both iterate the array as authored), so declaration order is the display order. Reorder the `areas` array instead.", + "note": "Contrast with nav-item `order`, which IS sorted (NavigationRenderer.tsx:1154) — the sibling that works is what makes this one read alive." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "display annotation no surface renders. Benign — docs-shaped, kept, not warned (hook.label precedent)." + }, + "visible": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it, or gate the items INSIDE the area — nothing evaluates an area-level `visible` predicate, so a 'hidden' area renders for everyone: a capability gate that fails open, the worst shape of the silent no-op (#4001's own words). Per-ITEM `visible` IS enforced (NavigationRenderer.tsx:891).", + "note": "The schema declares it with the same CEL wording as the enforced item-level key, which is exactly what makes it a trap." + }, + "requiredPermissions": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it, or gate per item / per app — no layer checks area-level permissions (the server's filterAppForUser walks only the top-level `navigation` tree, and the client area switcher renders every area). Per-item `requiredPermissions` are enforced server + client, and app-level `requiredPermissions` are enforced server-side (rest-server.ts:1814).", + "note": "Fail-open access gate — same class as `visible` above; the two are this ledger's most important app findings." + }, + "navigation": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:210, packages/layout/src/AppSchemaRenderer.tsx:469", + "note": "the active area's tree replaces the top-level navigation. NOTE: area trees are NOT server-side permission-stripped (filterAppForUser reads only `item.navigation`) — per-item gating inside an area is client-side only." + } + }, + "note": "Drilled because the gating keys diverge sharply from the live identity/tree keys." + }, + "contextSelectors": { + "children": { + "id": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:199", + "note": "also the nav template-variable name ({} substitution into recordId/params)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:232" + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:231" + }, + "optionsSource": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:90-125", + "note": "endpoint fetched, valueKey/labelKey dotted-path mapped, `filter` predicates applied per row (rowPasses, :69-71)." + }, + "includeAll": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "default(true) boolean — the lint cannot tell author-set from schema default, so marking it would warn on every selector.", + "note": "DELIBERATELY ignored by the renderer (ContextSelectors.tsx:242-246): selectors are mandatory-scope — an 'All' row would unscope the surface and, for Studio's package filter, leak system metadata. The renderer never shows an All option regardless of this flag. Candidate for retiredKey removal (the renderer comment is the prescription)." + }, + "allValue": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:177, :199, :246", + "note": "READ, but only as the 'nothing concrete selected' sentinel for auto-selection and query-param defaulting — its documented purpose ('value emitted when All is selected') can never occur because includeAll is ignored (see above). Live by the letter, vestigial by intent; re-verify if includeAll is ever removed." + }, + "persist": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/ContextSelectors.tsx:164", + "note": "'none' opts out of persistence; query/session honored." + }, + "placement": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "enum with default('sidebar_header') — the default materializes at compile, so presence ≠ authored (same rule as errorPolicy on mapping).", + "note": "no renderer reads it — selectors always render in the sidebar header block (AppSidebar:469-472); 'topbar' places nothing in the topbar." + } + }, + "note": "Drilled because includeAll/placement diverge (dead) from the live core." + }, + "homePageId": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. No shell reads it — the landing IS the first navigation item (in `order`), and the ROOT landing follows `isDefault` routing (objectui RootLandingRedirect). Reorder `navigation` or set `isDefault` instead.", + "note": "The schema's own hedge ('if not set, usually defaults to the first navigation item') describes the only behavior that exists." + }, + "requiredPermissions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:1814", + "note": "SERVER-enforced: an app whose required permissions are not a subset of the caller's system permissions is dropped from /meta entirely (and the single-item GET re-checks at rest-server.ts:3298)." + }, + "defaultAgent": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/app-shell/src/hooks/surfaceAgent.ts:81, packages/app-shell/src/layout/ChatDock.tsx:253", + "note": "bounded surface-binding knob (ADR-0063): resolved to the two platform agents (ask/build), alias-aware, anything else rejected — exactly as the schema documents." + }, + "version": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, 2026-06 audit) — authoring it is parse-rejected with the prescription (an app is versioned by its package's manifest.version). Row stays while the tombstone keeps the key in the walked shape." + }, + "aria": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — app-level ARIA was never read; declare aria on the rendering component/widget." + }, + "objects": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — objects belong to the stack; the ambient chatbot derives an app's object list from its nav items (collectNavObjects), never from App.objects." + }, + "apis": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — declarative endpoints belong to the stack." + }, + "sharing": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, ADR-0049) — a declared-but-unenforced security surface; the live sharing path is FormView.sharing." + }, + "embed": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142, ADR-0049) — no iframe route ever read it; embedding is per form view." + }, + "mobileNavigation": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "retiredKey tombstone (#4142) — fully unimplemented; returns if/when a real mobile navigation ships." + } + } +} diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json new file mode 100644 index 0000000000..153280b1f5 --- /dev/null +++ b/packages/spec/liveness/book.json @@ -0,0 +1,103 @@ +{ + "type": "book", + "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (packages/rest/src/rest-server.ts:3078-3169) driving the spec's pure resolveBookTree/audienceAllows (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). 15 of 17 live; the two dead entries are both inline `translations` maps that LOOK like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but have no resolver anywhere. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3098", + "note": "tree-route identity; an unknown name is treated as a package id and resolved as the implicit per-package book (§6.4)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:303", + "note": "carried into the resolved tree; portal cards fall back to `name`." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:341 (buildBookCards)", + "note": "portal landing-card subtitle. The framework tree omits it; the portal reads it off the raw /meta/book item." + }, + "translations": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. No resolver reads a book's inline translations map — the tree endpoint and the portal render `label`/`description` verbatim, and the generic bundle translator covers view/action/object/app/dashboard/page only (i18n-resolver.ts METADATA_DOCUMENT_TRANSLATORS), not `book`. Locale-variant doc CONTENT belongs on `doc.translations`, which IS enforced (resolveDocLocale); book/group titles currently have no i18n mechanism at all.", + "note": "The trap is proximity: `doc.translations` two files over works on every read path, so this map reads as the same feature. It is parsed and stored and nothing ever looks at it." + }, + "slug": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:132 (bookSlug), :339", + "note": "portal URL segment (`/docs/`), defaulting to `name`. Portal-side consumer only — the framework tree endpoint routes by `name`." + }, + "icon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:345 (buildBookCards)", + "note": "portal landing-card icon." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:302-312 (sortBooks)", + "note": "orders books on the portal landing (then label, stable). Authored books always sort ahead of synthetic per-package ones regardless of `order`." + }, + "audience": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3113, packages/rest/src/rest-server.ts:2969, packages/spec/src/system/book.zod.ts:351", + "note": "ENFORCED access gate (§6.7), fail-closed: gates the whole tree (401 anonymous / 403 non-holder), and every doc's effective audience is the union over the books that claim it (resolveDocAudiences) — applied to both doc lists and tree entries. The one security-shaped property on this type, and it is real." + }, + "groups": { + "children": { + "key": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:238, packages/spec/src/system/book.zod.ts:290", + "note": "group identity: explicit `doc.group` placement matches on it, and it keys the resolved tree." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:290", + "note": "section title in the resolved tree." + }, + "translations": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — see the book-level `translations` entry: no resolver reads inline book/group translations; group labels render verbatim in every locale.", + "note": "Same dead map one level down." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:221", + "note": "orders groups within the book (0 default, then declaration order)." + }, + "include": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:236, packages/spec/src/system/book.zod.ts:193", + "note": "the derived-membership rule — the heart of the §6.2.1 design. CAVEAT, recorded not hidden: only the GLOB form can match today. The `{ tag }` variant is declared and the resolver implements it (matchesInclude reads doc.tags), but DocSchema declares no `tags` property, so the corpus always carries `tags: undefined` and a tag rule matches nothing. Either add `doc.tags` or retire the variant (enforce-or-remove)." + }, + "package": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:232", + "note": "scopes the rule to a package id (cross-package books, ADR-0048)." + }, + "pages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:248-286", + "note": "explicit curated-order override; `---` separators and `...` rest-expansion both implemented, node label/badge/icon overrides carried into entries." + } + }, + "note": "Drilled because `translations` diverges (dead) from its six live siblings." + } + } +} diff --git a/packages/spec/liveness/doc.json b/packages/spec/liveness/doc.json new file mode 100644 index 0000000000..7227e2120d --- /dev/null +++ b/packages/spec/liveness/doc.json @@ -0,0 +1,48 @@ +{ + "type": "doc", + "_note": "DocSchema (ADR-0046 flat Markdown package docs). Fully live. The schema header calls docs 'inert data' — true of the KERNEL (it stores `content` unparsed), but every property has a real runtime consumer in the delivery layer: the REST read layer localizes, audience-gates and serves docs (packages/rest/src/rest-server.ts:2944-3022 list, :3384-3390 single item), and the `/meta/book/:name/tree` endpoint resolves book membership from doc headers via the spec's own resolveBookTree (packages/spec/src/system/book.zod.ts:218). objectui's console docs portal is a faithful port of the same resolver (apps/console/src/pages/book-nav.ts @940ba24) rendering the reader UI — a delivery surface for readers, NOT an authoring preview. Seeded 2026-08-01 (#4488). NOTE: DocSchema declares no `tags`, yet the book-side `include: { tag }` rule and the REST corpus (`d.tags`, rest-server.ts:2965) both expect one — the tag rule can currently never match; recorded on book.groups, not silently dropped.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:225, packages/rest/src/rest-server.ts:3129", + "note": "identity: the single-doc route key, the resolver's membership key (glob `include` matches over names), and the audience map key." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:198, packages/spec/src/system/book.zod.ts:202", + "note": "tree entry title + the order tiebreak sort key (byOrderThenLabel)." + }, + "description": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:202, packages/rest/src/rest-server.ts:3131", + "note": "carried into tree entries and kept on the list response (which strips `content`) so portals can show summaries without fetching bodies." + }, + "content": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:3007, packages/spec/src/system/doc.zod.ts:120", + "note": "the document body: served whole on single-doc GET, deliberately stripped from list responses unless `?include=content`, locale-swapped by resolveDocLocale." + }, + "order": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:198", + "note": "sort key within a book group (0 when absent, then label)." + }, + "group": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/book.zod.ts:238", + "note": "explicit book-group membership; a doc joins the group whose `key` equals it when no `include` rule claims it first." + }, + "translations": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/doc.zod.ts:110, packages/rest/src/rest-server.ts:2996, packages/rest/src/rest-server.ts:3388", + "note": "per-locale {label,description,content} variants collapsed by resolveDocLocale on every read path (list, tree corpus, single item); the map itself is stripped from responses. This is the doc's OWN i18n mechanism — the generic bundle translator does not cover `doc`." + } + } +} diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json new file mode 100644 index 0000000000..fe8734bb53 --- /dev/null +++ b/packages/spec/liveness/email_template.json @@ -0,0 +1,73 @@ +{ + "type": "email_template", + "_note": "EmailTemplateDefinitionSchema. THE ENTIRE AUTHORING SURFACE IS DEAD — the webhook (#3461) disconnect shape, verified 2026-08-01 by closing the graph from both ends. Enforcement end: IEmailService.sendTemplate resolves (name, locale) against sys_email_template ROWS and honors active/variables/fromOverride/replyTo from the ROW (packages/plugins/plugin-email/src/email-service.ts:404-465). Writer end: the ONLY writers of sys_email_template are the built-in auth templates plus code-constructed EmailServicePluginOptions.templates, both via upsertTemplate (packages/plugins/plugin-email/src/email-plugin.ts:358-371, :431-468) — and no bootstrapper passes `templates` (the CLI serve composition omits it, packages/cli/src/commands/serve.ts:2165). Authoring end: every door an author can use — stack `emailTemplates:` (ingested as metadata items, metadata/src/plugin.ts:89), `*.email-template.ts` files, Studio (whose nav points at the metadata-admin list, platform-objects/src/apps/studio.app.ts:331), PUT /meta — lands items in the metadata store that NOTHING reads back; the package importer even excludes emailTemplates explicitly (packages/runtime/src/domains/packages.ts:569-571). So an admin who 'fixes' the password-reset email in Studio sees it saved and users keep receiving the builtin — ADR-0078 false compliance on AUTH mail. Enforce-or-remove tracked in #4509; upsertTemplate's field mapping (email-plugin.ts:432-449) is the future materializer's mapping table, exactly as the sys_webhook column map was for webhooks (#3489 closed that one). Per the webhook precedent, ONE per-artifact authorWarn is carried on `name` rather than one per property. `protection`/_lock*/_provenance are framework overlay fields, auto-live.", + "props": { + "name": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Authoring an `email_template` metadata item does NOT register it with the mail service: sendTemplate reads sys_email_template rows, and nothing materializes metadata items into that table (see the type note). Until the bridge exists, outbound-mail templates are the built-in auth set plus code-supplied EmailServicePluginOptions.templates — a template authored here saves cleanly and is never used.", + "note": "Would-be row mapping: `name` (the sendTemplate lookup key, email-service.ts:404)." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `label` (email-plugin.ts:434). Warn carried on `name` — one heads-up per artifact, not per prop." + }, + "category": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `category` (email-plugin.ts:435); a Studio filter facet even on the live rows, never behavior." + }, + "locale": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `locale` (email-plugin.ts:436) — on live rows this IS enforced ((name, locale) resolution with en-US fallback, email-service.ts:410-416), which is what makes the inert metadata copy so misleading." + }, + "subject": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `subject` (email-plugin.ts:437); rendered with {{path}} holes by renderTemplate on live rows (email-service.ts:444)." + }, + "bodyHtml": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `body_html` (email-plugin.ts:438)." + }, + "bodyText": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `body_text` (email-plugin.ts:439); live rows auto-derive text from HTML when absent (htmlToText)." + }, + "variables": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `variables_json` (email-plugin.ts:448); on live rows `required` variables fail sends fast (requireVars, email-service.ts:427-431). All four child keys share this verdict — not drilled." + }, + "fromOverride": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `from_address`/`from_name` (email-plugin.ts:440-443). Both child keys share this verdict." + }, + "replyTo": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `reply_to` (email-plugin.ts:444); honored on live rows (email-service.ts:463-464)." + }, + "active": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `active` (email-plugin.ts:445); on live rows `active: false` makes sendTemplate return TEMPLATE_INACTIVE (email-service.ts:418). default(true) boolean — could not carry authorWarn even if we wanted one (the lint cannot tell author-set from schema default)." + }, + "isSystem": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `is_system` (email-plugin.ts:446); on live rows it gates re-seeding (a tenant-customised row is never overwritten, email-plugin.ts:459)." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "would-be row mapping: `description` (email-plugin.ts:447); docs-shaped even on live rows." + } + } +} diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json new file mode 100644 index 0000000000..36375f97de --- /dev/null +++ b/packages/spec/liveness/job.json @@ -0,0 +1,59 @@ +{ + "type": "job", + "_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:766-802) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP, recorded not hidden: `job` is registered `allowRuntimeCreate: true` (metadata-plugin.zod.ts:640) but ONLY the compiled bundle's `jobs` reach the scheduler — no code path schedules a runtime-authored `job` metadata item (a Studio-created job saves cleanly and never runs; its `handler` could not even resolve, since the function map lives in the bundle). Same disconnect class as webhook (#3461) — tracked in #4509. Seeded 2026-08-01.", + "props": { + "id": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:784), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.", + "note": "The describe() text ('defaults to `name` when omitted') implies an identity override that does not exist." + }, + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:767, packages/runtime/src/app-plugin.ts:784", + "note": "scheduling identity; a job without one is skipped loudly." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "display metadata; no runtime consumer (sys_job stores name/schedule only). Docs-shaped annotation, deliberately KEPT and not authorWarn'd — the hook.label/description precedent, exempt from enforce-or-remove (ADR-0033)." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "same as `label`: docs-shaped, deliberately kept, no warning." + }, + "schedule": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:786, packages/services/service-job/src/cron-job-adapter.ts:71-88, packages/services/service-job/src/db-job-adapter.ts:83", + "note": "all three variants enforced: cron `expression` + per-job `timezone` (cron-job-adapter.ts:76-77), interval `intervalMs` (:82), once `at` (:87); the db adapter persists the shape onto sys_job (db-job-adapter.ts:233-245). WALK BOUNDARY: a discriminated union — the gate classifies it as one property; the per-variant keys are covered by the adapter evidence above, not by ledger rows." + }, + "handler": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:776", + "note": "resolved against the bundle's function map; a missing handler skips the job with a warning rather than scheduling a no-op." + }, + "retryPolicy": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:791, packages/services/service-job/src/run-with-policy.ts:58-65", + "note": "maxRetries/backoffMs/backoffMultiplier all drive the exponential-backoff retry loop (delay = backoffMs * multiplier^(retry-1)). Enforced since #3494. This is the `retryPolicy` the datasource ledger warns about confusing with its dead namesake." + }, + "timeout": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/services/service-job/src/run-with-policy.ts:25-33", + "note": "per-attempt limit; an over-limit run records execution status 'timeout' (JobTimeoutError). The in-flight handler is abandoned, not cancelled — as documented." + }, + "enabled": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/runtime/src/app-plugin.ts:772", + "note": "`enabled: false` skips scheduling entirely at registration — genuinely enforced, unlike the retired flow.active/tool.active." + } + } +} diff --git a/packages/spec/liveness/mapping.json b/packages/spec/liveness/mapping.json new file mode 100644 index 0000000000..ff7c1202ba --- /dev/null +++ b/packages/spec/liveness/mapping.json @@ -0,0 +1,67 @@ +{ + "type": "mapping", + "_note": "MappingSchema (#2611 reusable import mapping). Consumers: the REST import path — resolveNamedMapping fetches the artifact by name and validates it against the request (packages/rest/src/import-mapping.ts:60-107), applyMappingToRows runs the fieldMapping pipeline (:115-167), and import-prepare adopts the artifact's mode/upsertKey as request defaults (packages/rest/src/import-prepare.ts:321-326); objectui's ImportWizard offers registered mappings in a saved-mapping picker (@940ba24 packages/plugin-grid/src/ImportWizard.tsx:979). 8 of 11 live. The IMPORT half of the schema is real and loudly enforced (unsupported transforms/formats are 400s, not silent skips — Prime Directive #10); the EXPORT half (`extractQuery`) and the tuning knobs (`errorPolicy`, `batchSize`) have no consumer anywhere. Seeded 2026-08-01 (#4488).", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:70", + "note": "artifact resolution key for the request's `mappingName` (missing → 404 MAPPING_NOT_FOUND)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: packages/plugin-grid/src/ImportWizard.tsx:979", + "note": "saved-mapping picker option text (`label || name`) and the applied-mapping hint. Display metadata with a real selection surface — the datasource.label treatment, not authorWarn'd." + }, + "sourceFormat": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:81-97", + "note": "declared-format gate: `xml`/`sql` are rejected outright (the import endpoint accepts csv/json/xlsx), and a csv-declared mapping applies to xlsx too; a mismatch is a 400, never a silent reinterpretation." + }, + "targetObject": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:75", + "note": "must equal the URL object or the import 400s (MAPPING_TARGET_MISMATCH)." + }, + "fieldMapping": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-mapping.ts:98-105, packages/rest/src/import-mapping.ts:115-167", + "note": "the pipeline itself: source/target/transform/params all consumed. none/constant/map/split/join applied in applyMappingToRows (`params.separator` :124, `.value` :132, `.valueMap` :137); `lookup` copies through for the pipeline's metaMap reference resolution; `javascript` is REJECTED with a 400 (no server sandbox — implement-or-reject-loudly). SUB-WALK BOUNDARY, recorded not hidden: `params`' lookup-specific keys (`object`/`fromField`/`toField`/`autoCreate`) are read by nothing — reference resolution comes from the target object's own field definitions, not from these — and they sit one level below the drill, so only this note governs them." + }, + "mode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-prepare.ts:322", + "note": "an artifact declaring update/upsert sets the import's writeMode default (an explicit request `writeMode` still wins)." + }, + "upsertKey": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/import-prepare.ts:325", + "note": "adopted as the upsert match fields when the request names none." + }, + "extractQuery": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it. 'Query to run for export only' promises an export path that does not exist — no exporter reads any mapping artifact. Exports run through the ordinary query API; when a mapping-driven export lands, this is where it plugs in, but authoring it today configures nothing.", + "note": "A whole QuerySchema subtree hangs off this optional key; the single flat verdict covers all of it (no consumer reaches any child)." + }, + "errorPolicy": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "schema default('skip') materializes at compile (defineMapping parses), so on the compiled stack the lint cannot tell an authored value from the default — a warn here would fire on every mapping artifact. Same reason default(true) booleans are never marked.", + "note": "No import code reads it: error handling on the import path is the request's own options, and 'retry'/'abort' configure nothing. Dead but unwarnable — see _authorWarnSkipped." + }, + "batchSize": { + "status": "dead", + "verifiedAt": "2026-08-01", + "_authorWarnSkipped": "schema default(1000) materializes at compile — same unwarnable shape as errorPolicy.", + "note": "No import code batches by it; the write path sizes its own batches." + } + } +} diff --git a/packages/spec/liveness/seed.json b/packages/spec/liveness/seed.json new file mode 100644 index 0000000000..9e24dfc113 --- /dev/null +++ b/packages/spec/liveness/seed.json @@ -0,0 +1,36 @@ +{ + "type": "seed", + "_note": "SeedSchema. Fully live — the smallest and healthiest surface in the ledger. Consumer: SeedLoaderService (packages/metadata-protocol/src/seed-loader.ts), reached on BOTH authoring paths: (1) boot/replay — the stack's `data:` collection lands in `manifest.data`, app-plugin.ts normalizes it and calls seedLoader.load() (packages/runtime/src/app-plugin.ts:832, :971), plus the per-org replayer registered for tenant provisioning; (2) runtime drafts — publishMetaItem applies a published `seed` draft through the same loader (packages/metadata-protocol/src/protocol.ts:6764, `skipSeedApply` opt-out for package batches). Seeded 2026-08-01 (#4488).", + "props": { + "object": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:98", + "note": "target object; also the dependency-graph node key (topological insert order)." + }, + "externalId": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:119", + "note": "upsert/uniqueness key, single or composite (framework#3434 join tables); also what OTHER datasets' reference values resolve against (buildReferenceMap threads it into the DB probe)." + }, + "mode": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:245", + "note": "insert/update/upsert/replace/ignore — drives decideWriteAction/writeRecord." + }, + "env": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:91", + "note": "filterByEnv drops datasets whose env list excludes the running environment." + }, + "records": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/metadata-protocol/src/seed-loader.ts:434", + "note": "the payload rows. WALK BOUNDARY: each record is a z.record — the keys an author actually writes are the TARGET OBJECT's field names, governed by that object's own field definitions (and the defineSeed factory's compile-time key check), not by this ledger. Recorded here rather than left implicit, per the datasource `config` precedent." + } + } +} diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json new file mode 100644 index 0000000000..772980651b --- /dev/null +++ b/packages/spec/liveness/translation.json @@ -0,0 +1,73 @@ +{ + "type": "translation", + "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). Registered schema is a z.preprocess pipe (the retired object-first-dialect guard), which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, settings..keys..options., …) are governed by the resolvers cited per row, not by ledger rows. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through — the resolvers read only the declared conventions. 10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost. Seeded 2026-08-01 (#4488).", + "props": { + "locale": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/core/src/fallbacks/authored-translation-sync.ts:140-148", + "note": "which bundle entry the item fills. Required for a reason the schema states: the sync SKIPS an item whose locale it cannot resolve — loudly (warn log), with a name-derived fallback for pre-#3778 rows." + }, + "objects": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:735, :751, :159, :197, :873, :900; objectui @940ba24: packages/i18n/src/useObjectLabel.ts:397-400", + "note": "the largest group, fully live: label/pluralLabel/description (translateObject), fields.{label,help,placeholder,options}, _views (resolveViewLabel + empty-state copy), _actions (label/confirmText/successMessage/params/resultDialog — object-scoped first, then globalActions fallback), _sections (objectui record:details section labels). Served through REST translateMetaItem(s) and the /api/v1/i18n endpoints; objectui re-resolves client-side via the spec-translations transform." + }, + "apps": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:442, :456; packages/rest/src/rest-server.ts:2001", + "note": "translateApp swaps app label/description and walks the navigation tree replacing node labels by id — applied on every /meta app read." + }, + "messages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts:562-579, :744-745", + "note": "consumed via II18nService.t: plugin-audit localizes activity-feed summaries (messages.activityCreated/Updated/Deleted, framework#3039) and collaboration notifications (messages.mentionedYou). Easy to mis-verify — no resolver in i18n-resolver.ts reads it; the consumer is a t() caller with composed keys, which a literal grep for the group name never finds." + }, + "validationMessages": { + "status": "dead", + "verifiedAt": "2026-08-01", + "authorWarn": true, + "authorHint": "Delete it — nothing resolves `validationMessages.` in either repo. A validation violation renders the rule's own authored `message` verbatim (rule-validator.ts), and the #3957 message-translation hook covers only the BUILT-IN field messages via the i18n service's `validation.field.*` keys. There is currently no per-locale override mechanism for rule messages; until one ships, translate by authoring per-locale rules or keep messages locale-neutral.", + "note": "The trap has the platform's own signature on it twice: the schema example shows a concrete override ({\"discount_limit\": \"折扣不能超过40%\"}), and #3778's legacy-key migration table steers retired `errors:` authors here ('use validationMessages for rule messages'). Both point at a group with no reader — the capabilities.readOnly shape. objectui's spec-translations transform passes the group through to the client tree, but no client code looks anything up under it (a passthrough is not a consumer)." + }, + "globalActions": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:200, :249", + "note": "the object-less fallback for action label/confirmText/successMessage/params/resultDialog — resolveAction* checks objects.._actions first, then here." + }, + "dashboards": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:538, :554", + "note": "translateDashboard: label/description plus per-widget title/description by widget id; header action labels." + }, + "pages": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/spec/src/system/i18n-resolver.ts:636", + "note": "translatePage: label/description/title/subtitle (title falls back to label; header copy keyed by page name because page:header instances carry no stable id)." + }, + "settings": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/settings/useSettingsLabel.ts:78", + "note": "the Settings UI resolves `.settings..{title,description,groups.*,keys.*,actions.*}` against the served tree — title/group/field/option/action labels all honored." + }, + "metadataForms": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/rest/src/rest-server.ts:2058, :2062", + "note": "translateMetaTypes decorates GET /meta types with resolveMetadataTypeLabel and localizes every form schema through resolveMetadataFormLabels (labels/sections/fields by dotted path) — the Studio metadata-editor localization path." + }, + "settingsCommon": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "objectui @940ba24: apps/console/src/pages/settings/useSettingsLabel.ts:104", + "note": "cross-namespace Settings chrome strings (source badges by resolution layer); the client scans every namespace carrying a settingsCommon block." + } + } +} diff --git a/packages/spec/liveness/validation.json b/packages/spec/liveness/validation.json new file mode 100644 index 0000000000..ff5707b73e --- /dev/null +++ b/packages/spec/liveness/validation.json @@ -0,0 +1,69 @@ +{ + "type": "validation", + "_note": "ValidationRuleSchema — the ADR-0020 carrier, where a wrong verdict is expensive, so the call graph was closed with extra care. The walked shape is the discriminated union's FIRST object member (the base keys + `script`'s type/condition — the #3095 union rule); per-variant keys (state_machine's field/transitions/initialStates, format's regex/format, json_schema's schema, conditional's when/then/otherwise, cross_field's fields) sit OUTSIDE the walk — an explicit blind spot recorded here (the union analog of the z.record rule), governed by the evaluator's own tests, not ledger rows. Consumer: the engine write path calls evaluateValidationRules on insert and on every matched update row (packages/objectql/src/engine.ts:3703, :4017, :4085) with rules from the OBJECT's embedded `validations` array (+ object_extension merge, engine.ts:1559). The evaluator provably honors every execution-control key — the zod header's prose claiming it 'only reads type/condition/field/events/severity/message' is STALE (it predates enforcement of active/priority) and should not be trusted over the ledger. TYPE-LEVEL GAP, recorded not hidden: a STANDALONE `validation` metadata item (file `*.validation.ts` or Studio — allowRuntimeCreate: true, metadata-plugin.zod.ts:602) never reaches any object's write path — the schema has no object-binding key, no merge code exists, and only the reference-tracker even expects one (metadata-protocol/src/protocol.ts:1306). A state machine authored through that door saves cleanly and gates nothing. The per-prop verdicts below are for rules where rules actually live (`object.validations` — the same schema instance); the standalone-door disconnect is tracked in #4509. Seeded 2026-08-01.", + "props": { + "name": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:665, packages/objectql/src/validation/rule-validator.ts:676", + "note": "names the rule in violation logs and the broken-rule skip warning." + }, + "label": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "governance/editor metadata, declared deliberately (the schema header says so): surfaced in rule listings, never evaluated on the write path. Docs-shaped, KEPT, not authorWarn'd — the hook.label precedent." + }, + "description": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "same as `label` — governance annotation, deliberately kept." + }, + "active": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:647", + "note": "`active: false` filters the rule out before evaluation — genuinely enforced, unlike the retired flow.active/tool.active (worth stating on a validation surface: an rls.enabled-shaped failure here would be a data-integrity hole)." + }, + "events": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:654", + "note": "insert/update dispatch (default both). `delete` was removed from the enum in #3184 after being proven a silent no-op — guard deletions with a beforeDelete hook." + }, + "priority": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:657", + "note": "stable low-number-first sort of the evaluation order." + }, + "tags": { + "status": "dead", + "verifiedAt": "2026-08-01", + "note": "categorization for reporting/management — governance metadata like label/description, deliberately kept." + }, + "severity": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:671-678", + "note": "only 'error' blocks the write; 'warning'/'info' violations are logged and let the write proceed." + }, + "message": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:676, packages/objectql/src/engine.ts:3703", + "note": "the author-written violation text carried on every FieldValidationError (surfaced as 400 VALIDATION_FAILED); per-deployment overrides resolve via validationMessages in the translation bundle (#3957) without touching the authored value." + }, + "type": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:692-706", + "note": "the union discriminant: dispatches to the state_machine/predicate/format/json_schema/conditional checkers; the schema admits exactly the handled set." + }, + "condition": { + "status": "live", + "verifiedAt": "2026-08-01", + "evidence": "packages/objectql/src/validation/rule-validator.ts:697", + "note": "the CEL predicate (script/cross_field variants), evaluated against the merged record + previous — TRUE fails the write." + } + } +} diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 57af2d48ed..59699beb9d 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -79,7 +79,7 @@ const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. // (`query` is not a metadata type — see SPEC_ONLY_SCHEMAS below.) -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'position', 'agent', 'tool', 'skill', 'dataset', 'page', 'view', 'report', 'dashboard', 'webhook', 'query', 'datasource', 'app', 'book', 'doc', 'email_template', 'job', 'mapping', 'seed', 'translation', 'validation']; // Registered metadata types that are NOT yet governed — the coverage ratchet. // @@ -104,15 +104,12 @@ const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'po // the failure this map exists to make visible, and an entry with no issue is // indistinguishable from never having looked. const PENDING_GOVERNANCE: Record = { - app: 'the #4001 app step retired seven dead keys without seeding a ledger, so the rest of AppSchema is unclassified — #4488', - book: 'ADR-0046 documentation spine; no property audit done — #4488', - doc: 'ADR-0046 flat Markdown docs; small surface, unaudited — #4488', - email_template: 'unaudited — #4488', - job: 'unaudited; `job.retryPolicy` IS enforced (runtime/src/app-plugin.ts:791), so this one must not be assumed dead — #4488', - mapping: '#2611 reusable import mapping; unaudited — #4488', - seed: 'fixture/init data applied on publish; unaudited — #4488', - translation: 'unaudited — #4488', - validation: 'ValidationRuleSchema carries the ADR-0020 record state machine, so a wrong verdict is expensive; unaudited — #4488', + // EMPTY since #4488 paid off all nine debts the map opened with (app, book, + // doc, email_template, job, mapping, seed, translation, validation) — every + // registered type is governed. The map stays because the ratchet is the + // point, not the entries: registering a NEW type without a ledger fails CI + // with instructions to either govern it or record the debt here (reason + + // issue number). Do not add an entry just to silence the gate. }; // Spec-only override: governed types whose canonical schema is NOT (yet) in the @@ -169,7 +166,18 @@ function unwrap(s: any, depth = 0): any { if (!def) return s; if (def.type === 'lazy' && typeof def.getter === 'function') return unwrap(def.getter(), depth + 1); if (['optional', 'default', 'nullable', 'readonly', 'catch', 'nonoptional', 'prefault'].includes(def.type)) return unwrap(def.innerType, depth + 1); - if (def.type === 'pipe') return unwrap(def.in ?? def.out, depth + 1); + if (def.type === 'pipe') { + // Two pipes, opposite authorable sides. `a.pipe(b)` authors against the IN + // side (a is the accepted input shape). `z.preprocess(fn, schema)` also + // compiles to a pipe, but its IN side is the preprocess TRANSFORM — the + // authorable surface is the OUT schema. Until #4488 this branch always took + // `def.in`, so a preprocess-wrapped registration (TranslationItemSchema's + // retired-dialect guard, #3778) unwrapped to the transform, walked to no + // shape, and made `translation` ungovernable. + const inDef = defOf(unwrap(def.in, depth + 1)); + if (inDef?.type === 'transform') return unwrap(def.out, depth + 1); + return unwrap(def.in ?? def.out, depth + 1); + } return s; } function shapeOf(s: any): Record | null {