From 5b8f2d881ae9b91c7d8796c8d031d894dc6e8dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 20:02:24 +0000 Subject: [PATCH 1/2] docs(adr): migrate rotted line anchors to resolver-gated symbol anchors (#13556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #13556 census enumerated every line anchor in `docs/adr/**` — 343 across 35 of the 134 records, plus 52 continuation anchors — and resolved each against main. Excluding 4 HISTORICAL and 2 EXTERNAL, 243 of 337 live anchors were broken: 72.1%, declared as a one-way LOWER bound. Rot tracked target-file CHURN rather than record age: the four hottest targets were 100% broken across 50 anchors. A rotted line anchor does not fail — it silently points a reader at unrelated code inside the document whose purpose is to be a durable record. Maintainer ruling 2026-09-01 (总监批 #27), option A: migrate the corpus to symbol anchors behind a resolver gate that reds on an absent symbol, in ONE migration with no transition period, sharing a single resolver with #13788. - `scripts/symbol-anchors.mjs` — the SHARED core: grammar, extractor and the resolution rule. A corpus is a `defineCorpus` registration, so #13788 joins by registering rather than by forking the resolver. - `scripts/check-adr-symbol-anchors.mjs` — the `docs/adr/**` registration. - 469 occurrences rewritten across 35 records: 188 became symbol anchors (173 resolved at a declaration site, 15 as a quoted data identifier), 211 dropped to file level, 15 to cross-repo file level, 10 dead paths lost their number, 24 duplicate or orphaned continuations were removed, and 6 HISTORICAL / EXTERNAL anchors carry an inline exemption marker. Resolution is deliberately stricter than the census: comments are stripped before matching, so a symbol named only in a comment does not resolve, and a token that merely repeats the anchor's own path is refused. That strictness is what confirms 72.1% was a floor — `object.zod.ts` was credited with `stateMachines` by the census, and the only occurrence today is a comment saying that map no longer exists. Nine spellings of a line reference are recognised, three of which the census's own extractor could not see: comma and slash lists, the trailing `+` form, bare anchors in running prose, fenced comment headers, and the tilde form. ⛔ No ADR's decision content changes; only anchor form does. ADR-0113's Context row is known to state the inverse of today's mechanism — carded as #14193, deliberately NOT repaired here, with a note in the record pointing at it. Refs #13556 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mszp61SetNhQfVGvWh9bNn --- .github/workflows/lint.yml | 56 ++ .../0005-metadata-customization-overlay.md | 6 +- .../0015-external-datasource-federation.md | 6 +- ...16-studio-package-authoring-and-publish.md | 2 +- docs/adr/0019-approval-as-flow-node.md | 4 +- ...0020-state-machine-converge-and-enforce.md | 14 +- .../0021-analytics-dataset-semantic-layer.md | 4 +- ...metadata-naming-and-namespace-isolation.md | 16 +- ...ship-and-platform-objects-decomposition.md | 128 ++-- docs/adr/0032-unified-expression-layer.md | 10 +- .../0049-no-unenforced-security-properties.md | 10 +- .../0052-audit-is-not-the-activity-feed.md | 14 +- docs/adr/0053-date-and-datetime-semantics.md | 34 +- ...0055-master-detail-controlled-by-parent.md | 14 +- ...6-permission-model-landing-verification.md | 26 +- ...ion-core-business-units-and-scope-depth.md | 52 +- docs/adr/0061-record-search-architecture.md | 4 +- docs/adr/0065-sdui-styling-model.md | 10 +- docs/adr/0070-package-first-authoring.md | 4 +- .../0072-reference-scope-and-resolvability.md | 26 +- docs/adr/0079-record-display-name.md | 46 +- docs/adr/0080-ai-authored-ui-jsx-source.md | 10 +- ...-boundary-and-cross-package-composition.md | 60 +- .../0089-unify-visibility-predicate-naming.md | 20 +- ...96-execution-surface-identity-admission.md | 30 +- ...e-adjudicated-tiering-and-external-rung.md | 14 +- ...0104-field-runtime-value-shape-contract.md | 24 +- ...nancy-posture-and-first-class-org-scope.md | 28 +- .../0112-error-code-vocabulary-and-ledger.md | 28 +- ...red-write-contract-vs-column-constraint.md | 20 +- .../0117-owning-business-unit-record-stamp.md | 16 +- docs/adr/0118-non-user-actor-contract.md | 10 +- ...le-transactions-and-honest-atomic-batch.md | 28 +- ...6-packaged-metadata-customization-model.md | 20 +- ...che-invalidation-contract-and-ttl-bound.md | 8 +- ...lease-artifact-as-co-ownership-boundary.md | 78 +- package.json | 1 + scripts/check-adr-symbol-anchors.mjs | 241 ++++++ scripts/symbol-anchors.mjs | 707 ++++++++++++++++++ 39 files changed, 1423 insertions(+), 406 deletions(-) create mode 100644 scripts/check-adr-symbol-anchors.mjs create mode 100644 scripts/symbol-anchors.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b09e86486f..ba5645eb35 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2148,6 +2148,62 @@ jobs: - name: ADR anchors + number uniqueness (governed code names its decision) run: pnpm check:adr-anchors + # #13556 ADR SYMBOL ANCHORS — the other direction of the same contract. + # `check:adr-anchors` above asks whether governed CODE still names its + # ADR; this asks whether an ADR's pointer INTO code still resolves. + # + # The census on #13556 enumerated every line anchor in docs/adr/** — 343 + # across 35 records, plus 52 continuation anchors, a 395-anchor surface — + # and found 243 of 337 live anchors BROKEN: 72.1%, declared as a one-way + # LOWER bound because the census's own test accepted a symbol appearing + # in a comment. Rot tracked target-file CHURN, not ADR age: the four + # hottest targets were 100% broken across 50 anchors. A line number into + # a 16,000-line file has a lifetime measured in days, and it does not + # fail loudly — it silently points a reader at unrelated code. + # + # Maintainer ruling 2026-09-01 (总监批 #27), option A: migrate the whole + # corpus to symbol anchors with a resolver that reds on an absent symbol, + # in ONE migration with no transition period (「C 不作过渡」). That is why + # a surviving `path:NNN` is a hard finding rather than a warning here. + # + # ⭐ The mechanism is SHARED, by the same ruling (「共享同一个 resolver, + # ⛔ 不造第二套」): scripts/symbol-anchors.mjs holds the grammar, extractor + # and resolution rule, and this gate is a `defineCorpus` registration. + # #13788 (1,647 platform-checklist citations) joins by registering its own + # corpus — ⛔ not by forking the resolver. + # + # Invoked as `node` rather than a pnpm alias per the GATE INVOCATION IDIOM + # at the top of this file; the gate's own --self-test asserts BOTH lines + # below are present, so rewiring it silently reddens it. + # + # ⚠️ The two self-tests go through a `run_self_test` COLLECTOR, not a bare + # sequence. Under `bash -e` the first non-zero exit aborts the step, so a + # chained second self-test is neither green nor red — it never runs + # (#10814). The collector runs both unconditionally and fails at the end + # naming every one that broke. + - name: ADR symbol anchors resolve (no line numbers survive) + run: | + failed="" + run_self_test() { + echo "-- $*" + if "$@"; then + echo "PASS $*" + else + echo "FAIL $*" + failed="${failed} $*"$'\n' + fi + return 0 + } + run_self_test node scripts/symbol-anchors.mjs --self-test + run_self_test node scripts/check-adr-symbol-anchors.mjs --self-test + if [ -n "$failed" ]; then + echo "" + echo "ADR symbol-anchor self-tests — the following FAILED:" + printf "%s" "$failed" + exit 1 + fi + node scripts/check-adr-symbol-anchors.mjs + # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) # is validated by `pnpm check:platform-checklist`, but by MAINTAINER # DECISION it is NOT wired into CI — it runs on a periodic manual cadence diff --git a/docs/adr/0005-metadata-customization-overlay.md b/docs/adr/0005-metadata-customization-overlay.md index 77ac9fa7c3..32f1c13cd6 100644 --- a/docs/adr/0005-metadata-customization-overlay.md +++ b/docs/adr/0005-metadata-customization-overlay.md @@ -77,8 +77,8 @@ Studio ships an in-browser editor for views and dashboards. Customers expect "Sa Until today, the runtime did not: 1. `PUT /api/v1/meta/view/` in **project-kernel mode** updated only the in-memory registry and returned `200 { success: true, message: "Saved to memory registry (project kernel — sys_metadata is control-plane only)" }`. The change vanished on restart. -2. `GET /api/v1/meta/view/` skipped `sys_metadata` entirely in project-kernel mode (`packages/objectql/src/protocol.ts:357`, `:369`). -3. `loadMetaFromDb()` returned `{ loaded: 0 }` early in project-kernel mode (`:1230`), so even if rows existed they would never be hydrated. +2. `GET /api/v1/meta/view/` skipped `sys_metadata` entirely in project-kernel mode (`packages/metadata-protocol/src/protocol.ts#sys_metadata`). +3. `loadMetaFromDb()` returned `{ loaded: 0 }` early in project-kernel mode (`#loadMetaFromDb`), so even if rows existed they would never be hydrated. Worse, a separate detour — Studio's "Duplicate View" calls `POST /api/v1/data/sys_view` — wrote rows to a **physical projection table** (`sys_view`, 21 flat columns) that has nothing to do with the metadata protocol path. The same applies to `sys_flow`, `sys_agent`, `sys_tool`, `sys_object`. Each of these tables duplicates a Zod schema already defined in `@objectstack/spec` (`ui/view.zod.ts`, `automation/flow.zod.ts`, etc.) and goes out of sync the moment the spec evolves. @@ -330,7 +330,7 @@ Implementation (`packages/objectql/src/protocol.ts`): - `saveMetaItem` runs `safeParse`. On failure, throws an error with `code='invalid_metadata'`, `status=422`, and a structured `issues` array carrying `path/message/code` for each Zod issue. REST layer - (`packages/rest/src/rest-server.ts:973-979`) already propagates `status` + (`packages/rest/src/rest-server.ts`) already propagates `status` and `code` to the response. - The persisted document is the **original** `request.item`, NOT `parsed.data`. Studio attaches auxiliary fields (`isPinned`, diff --git a/docs/adr/0015-external-datasource-federation.md b/docs/adr/0015-external-datasource-federation.md index c41c5d8c17..9dc622686d 100644 --- a/docs/adr/0015-external-datasource-federation.md +++ b/docs/adr/0015-external-datasource-federation.md @@ -16,7 +16,7 @@ The protocol already includes: - `Datasource` (`packages/spec/src/data/datasource.zod.ts`) — connection + driver + capabilities (transactions, queryFilters, readOnly, …). -- `Object.datasource` (`packages/spec/src/data/object.zod.ts:432`) — per-object +- `Object.datasource` (`packages/spec/src/data/object.zod.ts#datasource`) — per-object routing key, defaulting to `'default'`. - `ISchemaDiffService` (`packages/spec/src/contracts/schema-diff-service.ts`) — introspect → diff → migration plan. @@ -386,8 +386,8 @@ if (datasource.schemaMode !== 'managed') { } ``` -**Concretely** — `packages/drivers/driver-sql/src/sql-driver.ts:1064` -and `:1084` (current `createTable` / `alterTable` call sites) gain a +**Concretely** — `packages/drivers/driver-sql/src/sql-driver.ts` +and `packages/drivers/driver-sql/src/sql-driver.ts` (current `createTable` / `alterTable` call sites) gain a guard at the top. The `applyMigrations` implementation (forthcoming in `service-migration` per ADR-0008) also calls this guard. diff --git a/docs/adr/0016-studio-package-authoring-and-publish.md b/docs/adr/0016-studio-package-authoring-and-publish.md index 0fd9e2e182..c28e0530c2 100644 --- a/docs/adr/0016-studio-package-authoring-and-publish.md +++ b/docs/adr/0016-studio-package-authoring-and-publish.md @@ -42,7 +42,7 @@ authoring loop: But the **authoring → publish closed loop is not yet implemented**. Today: -1. Studio create/edit (`ResourceEditPage.tsx:733`) calls +1. Studio create/edit (`objectui:ResourceEditPage.tsx`) calls `client.save(type, name, item, { force, mode:'draft' })` with **no `packageId`**. Per ADR-0003 this produces a **runtime/overlay** row (`env_id` set, `package_id` NULL, loaded under sentinel `'sys_metadata'`, diff --git a/docs/adr/0019-approval-as-flow-node.md b/docs/adr/0019-approval-as-flow-node.md index bd28228ed9..e9691a9357 100644 --- a/docs/adr/0019-approval-as-flow-node.md +++ b/docs/adr/0019-approval-as-flow-node.md @@ -27,7 +27,7 @@ The platform is not launched; there is no production approval data and no legacy ADR-0018 §Context argued — correctly — that *multiple authoring paradigms are fine; multiple execution vocabularies are not*. Approval is where that line is currently crossed at the **engine** level, not just the vocabulary level: - `@objectstack/plugin-approvals` is ~1500 LOC of runtime: an 816-line `approval-service.ts` state machine, a **313-line parallel `action-executor.ts`**, 250-line lifecycle hooks, and a 128-line plugin. -- The contract is explicit that this is a separate engine: [`spec/contracts/approval-service.ts:11`](../../packages/spec/src/contracts/approval-service.ts#L11) — *"Sits on top of (but does not depend on) `IWorkflowService` … driven by humans rather than transition rules."* +- The contract is explicit that this is a separate engine: [`packages/spec/src/contracts/approval-service.ts`](../../packages/spec/src/contracts/approval-service.ts) — *"Sits on top of (but does not depend on) `IWorkflowService` … driven by humans rather than transition rules."* - The parallel `action-executor.ts` re-implements `field_update` / `inbox_notify` / `webhook` and carries the **same** `connector_action` / `script` / `email_alert` "unimplemented, logged + skipped" stubs that ADR-0018 set out to retire. - It has its **own** ADR-0009 execution pinning (`process_hash` → `getByHash`), parallel to Flow's. - It registers its own lifecycle hooks: `afterInsert` auto-trigger, `beforeUpdate` record-lock ([`plugin-approvals/src/lifecycle-hooks.ts`](../../packages/plugins/plugin-approvals/src/lifecycle-hooks.ts)). @@ -68,7 +68,7 @@ There is **one** execution loop: the Flow engine. The engine core owns a generic The Approval node is registered through the **ADR-0018 open registry** (`registerNodeExecutor`), by a slimmed-down approval plugin — **not** baked into `service-automation` core. Rationale: - It is the ADR-0018 thesis applied to ourselves: the engine is the substrate, capabilities are contributed nodes. -- **Layering.** Approver resolution depends on the org / sharing model — `sys_team`, `sys_department` (recursive BFS), `sys_user.manager_id`, `sys_department_member` ([`plugin-approvals/src/approval-service.ts:175`](../../packages/plugins/plugin-approvals/src/approval-service.ts#L175)). The Flow engine core must **not** depend on the org model; the approval plugin may. So approval cannot live in core. +- **Layering.** Approver resolution depends on the org / sharing model — `sys_team`, `sys_department` (recursive BFS), `sys_user.manager_id`, `sys_department_member` ([`packages/plugins/plugin-approvals/src/approval-service.ts#sys_team`](../../packages/plugins/plugin-approvals/src/approval-service.ts)). The Flow engine core must **not** depend on the org model; the approval plugin may. So approval cannot live in core. - `service-automation` stays lean; approval becomes a well-behaved node provider that rides the engine instead of a parallel engine. ### D3 — Deprecate `ApprovalProcessSchema` as a top-level authoring type; re-home its concepts diff --git a/docs/adr/0020-state-machine-converge-and-enforce.md b/docs/adr/0020-state-machine-converge-and-enforce.md index 475bebe72d..6261ae62fb 100644 --- a/docs/adr/0020-state-machine-converge-and-enforce.md +++ b/docs/adr/0020-state-machine-converge-and-enforce.md @@ -34,22 +34,22 @@ The design intent is a **runtime guardrail**: declare which `status` transitions | # | Where | Schema | Reference | |---|-------|--------|-----------| -| 1 | Top-level `workflow` metadata type | `StateMachineSchema` (XState-style: hierarchical/parallel states, entry/exit actions, guards, context) | [`metadata-type-schemas.ts:85`](../../packages/spec/src/kernel/metadata-type-schemas.ts#L85), [`metadata-plugin.zod.ts:90`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L90), [`metadata-plugin.zod.ts:612`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L612) | -| 2 | Object-embedded | `object.stateMachines: Record` ("parallel lifecycles: status, payment, approval") | [`object.zod.ts:534`](../../packages/spec/src/data/object.zod.ts#L534) | -| 3 | Validation rule | `state_machine` rule: `transitions: { fromState: [toStates] }` | [`validation.zod.ts:105`](../../packages/spec/src/data/validation.zod.ts#L105) | +| 1 | Top-level `workflow` metadata type | `StateMachineSchema` (XState-style: hierarchical/parallel states, entry/exit actions, guards, context) | [`packages/spec/src/kernel/metadata-type-schemas.ts`](../../packages/spec/src/kernel/metadata-type-schemas.ts), [`packages/spec/src/kernel/metadata-plugin.zod.ts`](../../packages/spec/src/kernel/metadata-plugin.zod.ts) | +| 2 | Object-embedded | `object.stateMachines: Record` ("parallel lifecycles: status, payment, approval") | [`packages/spec/src/data/object.zod.ts`](../../packages/spec/src/data/object.zod.ts) | +| 3 | Validation rule | `state_machine` rule: `transitions: { fromState: [toStates] }` | [`packages/spec/src/data/validation.zod.ts#transitions`](../../packages/spec/src/data/validation.zod.ts) | **Zero enforcement — verified across `packages/{runtime,objectql,services,core,metadata*,plugins}` and the whole repo:** -- `IWorkflowService` (`packages/spec/src/contracts/workflow-service.ts:58` — unlinked: the contract file was deleted on 2026-08-01 by #4451 / #4473, which retired the `workflow` service slot outright, closing the follow-up this record left open below) has **no concrete implementation**. +- `IWorkflowService` (`packages/spec/src/contracts/workflow-service.ts:58` — unlinked: the contract file was deleted on 2026-08-01 by #4451 / #4473, which retired the `workflow` service slot outright, closing the follow-up this record left open below) has **no concrete implementation**. - There is **no XState interpreter** anywhere (no `createMachine` / `interpret` / transition engine). - The write-path validator [`validateRecord`](../../packages/objectql/src/validation/record-validator.ts#L198) reads only `objectSchema.fields` and validates **field data types** (string/number/date/…). It **never reads `objectSchema.validations`** at all — so *not one* of the nine validation-rule types (`state_machine`, `cross_field`, `script`, `unique`, `format`, `json_schema`, `async`, `custom`, `conditional`) is enforced by it. - **Nothing reads `object.stateMachines`.** -So the guardrail goal is currently unmet at runtime. The only artefacts that exist are declarations — e.g. `examples/app-crm/src/workflows/stale-opportunity.workflow.ts:19` (`StateMachineConfig`; unlinked — this file describes the pre-ADR state and was itself removed by this record's own implementation, see the checklist below), which additionally **mixes orchestration into the machine** (it carries `email_alert` / `task_creation` actions that no engine executes — that orchestration belongs to a record-triggered Flow per ADR-0019). +So the guardrail goal is currently unmet at runtime. The only artefacts that exist are declarations — e.g. `examples/app-crm/src/workflows/stale-opportunity.workflow.ts:19` (`StateMachineConfig`; unlinked — this file describes the pre-ADR state and was itself removed by this record's own implementation, see the checklist below), which additionally **mixes orchestration into the machine** (it carries `email_alert` / `task_creation` actions that no engine executes — that orchestration belongs to a record-triggered Flow per ADR-0019). #### The prior-state plumbing gap (the real implementation constraint) -A transition check needs **both** the prior and the new state. But the write path can't supply the prior state today: on update, [`engine.ts:1850`](../../packages/objectql/src/engine.ts#L1850) calls `validateRecord(schema, hookContext.input.data, 'update')` — passing only the **PATCH payload**, not the prior record. On `PATCH { status: 'done' }` there is no way to know the *from*-state without a read. So enforcing `state_machine` is not just "add a dispatch branch"; it requires **plumbing the prior (or merged) record into the rule-evaluation step**. This is a shared need: `cross_field` and `script` rules are equally crippled by receiving only the patch — so the fix should land **once for the whole `validations` union**, not as a `state_machine`-only patch (see D3). +A transition check needs **both** the prior and the new state. But the write path can't supply the prior state today: on update, [`packages/objectql/src/engine.ts#validateRecord`](../../packages/objectql/src/engine.ts) calls `validateRecord(schema, hookContext.input.data, 'update')` — passing only the **PATCH payload**, not the prior record. On `PATCH { status: 'done' }` there is no way to know the *from*-state without a read. So enforcing `state_machine` is not just "add a dispatch branch"; it requires **plumbing the prior (or merged) record into the rule-evaluation step**. This is a shared need: `cross_field` and `script` rules are equally crippled by receiving only the patch — so the fix should land **once for the whole `validations` union**, not as a `state_machine`-only patch (see D3). ### The design-center shift: AI is the author — optimise naming for the model's priors @@ -84,7 +84,7 @@ The introspectability is the upgrade that serves the two design centers: **UI** ### Where it lives: one of nine validation-rule types -`state_machine` is one variant of the `ValidationRuleSchema` discriminated union ([`validation.zod.ts:362`](../../packages/spec/src/data/validation.zod.ts#L362)), alongside `script`, `unique`, `format`, `cross_field`, `json_schema`, `async`, `custom`, and `conditional`. It shares `BaseValidationSchema` (name/label/message/severity) and the same write-time enforcement semantics as its siblings. This is *why it stays in `validations`* (D1) rather than becoming a standalone metadata type or file: it is, precisely, a write-time validation whose payload happens to be a transition graph. +`state_machine` is one variant of the `ValidationRuleSchema` discriminated union ([`packages/spec/src/data/validation.zod.ts#ValidationRuleSchema`](../../packages/spec/src/data/validation.zod.ts)), alongside `script`, `unique`, `format`, `cross_field`, `json_schema`, `async`, `custom`, and `conditional`. It shares `BaseValidationSchema` (name/label/message/severity) and the same write-time enforcement semantics as its siblings. This is *why it stays in `validations`* (D1) rather than becoming a standalone metadata type or file: it is, precisely, a write-time validation whose payload happens to be a transition graph. ## Decision diff --git a/docs/adr/0021-analytics-dataset-semantic-layer.md b/docs/adr/0021-analytics-dataset-semantic-layer.md index f545eae0fa..cda80e86cc 100644 --- a/docs/adr/0021-analytics-dataset-semantic-layer.md +++ b/docs/adr/0021-analytics-dataset-semantic-layer.md @@ -11,7 +11,7 @@ ## TL;DR -The platform's **query *schema*** (`QuerySchema`, [`query.zod.ts:586`](../../packages/spec/src/data/query.zod.ts#L586)) already *describes* `joins` (inner/left/right/full + strategies + subquery + cross-datasource), `aggregations`, `groupBy` (with date bucketing), `having`, and `windowFunctions`. +The platform's **query *schema*** (`QuerySchema`, [`packages/spec/src/data/query.zod.ts#QuerySchema`](../../packages/spec/src/data/query.zod.ts)) already *describes* `joins` (inner/left/right/full + strategies + subquery + cross-datasource), `aggregations`, `groupBy` (with date bucketing), `having`, and `windowFunctions`. > **Correction (revised 2026-05-31).** An implementation scan found the *runtime* does **not** match the schema: `groupBy` + `aggregations` execute (single-object only), but `joins` / `having` / `windowFunctions` are **schema-only — not executed** by `IDataEngine` or the SQL driver. A separate, already-implemented **Cube semantic layer** (`IAnalyticsService` + `CubeSchema`) is the *only* path that emits cross-object joins today (and it bypasses RLS/tenant). This reframes the work and the naming — see the "Implementation scan" section. The decisions below stand; the cost and the build-vs-reuse choice change. @@ -357,7 +357,7 @@ So "revenue by `account.region`" — the headline dataset use case — **cannot ### Finding 2 — a parallel semantic layer already exists (must reconcile) -`data/analytics.zod.ts` `CubeSchema` + `contracts/analytics-service.ts` `IAnalyticsService` are **implemented** (`AnalyticsService` in `service-analytics`, `MemoryAnalyticsService` in `driver-memory`): a Cube.io-style `{ measures, dimensions, timeDimensions }` layer — conceptually the same "semantic layer" this ADR proposes. Its `NativeSQLStrategy` is the **only** code that emits cross-object `LEFT JOIN` (single-hop), but via raw `engine.execute()` which **bypasses the sharing-middleware RLS and tenant isolation** ([`engine.ts:2077`](../../packages/objectql/src/engine.ts#L2077) warns explicitly). It loads opt-in (`requires: ['analytics']`), and its grammar is **disjoint from `QuerySchema`** — nothing compiles `QuerySchema.joins/having/window` to execution. +`data/analytics.zod.ts` `CubeSchema` + `contracts/analytics-service.ts` `IAnalyticsService` are **implemented** (`AnalyticsService` in `service-analytics`, `MemoryAnalyticsService` in `driver-memory`): a Cube.io-style `{ measures, dimensions, timeDimensions }` layer — conceptually the same "semantic layer" this ADR proposes. Its `NativeSQLStrategy` is the **only** code that emits cross-object `LEFT JOIN` (single-hop), but via raw `engine.execute()` which **bypasses the sharing-middleware RLS and tenant isolation** ([`packages/objectql/src/engine.ts`](../../packages/objectql/src/engine.ts) warns explicitly). It loads opt-in (`requires: ['analytics']`), and its grammar is **disjoint from `QuerySchema`** — nothing compiles `QuerySchema.joins/having/window` to execution. **Implication:** do not build a *third* semantic layer. Either (b) adopt/extend Cube as the dataset, or (c) compile `dataset` → `AnalyticsQuery` and reuse the Cube runtime (then harden its RLS/tenant). Both are far cheaper than teaching `IDataEngine` to join. diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md index 48c2ef5190..682e8e34ff 100644 --- a/docs/adr/0028-metadata-naming-and-namespace-isolation.md +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -63,16 +63,16 @@ unmanaged for everything except objects. | Area | Finding | Location | |:--|:--|:--| -| Prefix enforcement | `validateNamespacePrefix()` iterates **only `config.objects`** (`if (!ns || !config.objects) return`). The other ~23 collections are unchecked. | `spec/src/stack.zod.ts:459` | -| Authoring style | Object names are the **hand-written full literal** `crm_account`; docs explicitly forbid a `ns('task')` helper. | `spec/src/kernel/manifest.zod.ts:28-76` | -| Storage chokepoint | `StorageNameMapping.resolveTableName({name})` already exists, but is a **pass-through** (`todo_task → todo_task`, strips legacy `__`). Every SQL driver routes table names through it. | `spec/src/system/constants/system-names.ts:169`; `driver-sql/src/sql-driver.ts:610,1028` | +| Prefix enforcement | `validateNamespacePrefix()` iterates **only `config.objects`** (`if (!ns || !config.objects) return`). The other ~23 collections are unchecked. | `packages/spec/src/stack.zod.ts#validateNamespacePrefix` | +| Authoring style | Object names are the **hand-written full literal** `crm_account`; docs explicitly forbid a `ns('task')` helper. | `packages/spec/src/kernel/manifest.zod.ts` | +| Storage chokepoint | `StorageNameMapping.resolveTableName({name})` already exists, but is a **pass-through** (`todo_task → todo_task`, strips legacy `__`). Every SQL driver routes table names through it. | `packages/spec/src/system/constants/system-names.ts#StorageNameMapping`; `packages/drivers/driver-sql/src/sql-driver.ts` | | Object identity | `MetaRef = (org, type, name)` and `SchemaRegistry` already model ownership + namespace. | `metadata-core/src/types.ts`; `objectql/src/registry.ts` | -| Ownership model | `own`/`extend` fully implemented: one owner enforced (`throw`), extenders merge by `priority` (owner 100, extender 200). **No package actually extends a `sys_` object today.** | `objectql/src/registry.ts:406-518`; `object.zod.ts:856-897` | -| Connector collisions | Re-registering a connector name only `logger.warn('… replaced')` then overwrites — **silent last-wins**. | `services/service-automation/src/engine.ts:441` | +| Ownership model | `own`/`extend` fully implemented: one owner enforced (`throw`), extenders merge by `priority` (owner 100, extender 200). **No package actually extends a `sys_` object today.** | `packages/objectql/src/registry.ts`; `packages/spec/src/data/object.zod.ts` | +| Connector collisions | Re-registering a connector name only `logger.warn('… replaced')` then overwrites — **silent last-wins**. | `packages/services/service-automation/src/engine.ts` | | API routes | Route conflict detection exists with 4 strategies, but matches routes by **exact string** (`:id` vs `:userId` not detected). | `core/src/api-registry.ts` | -| Kernel namespace | `sys` is a **shared** namespace co-claimed by ~14 packages (`namespaceRegistry: Map>`); `RESERVED_NAMESPACES = {'base','system'}` does **not** include `sys`. | `objectql/src/registry.ts:13,346-389` | +| Kernel namespace | `sys` is a **shared** namespace co-claimed by ~14 packages (`namespaceRegistry: Map>`); `RESERVED_NAMESPACES = {'base','system'}` does **not** include `sys`. | `packages/objectql/src/registry.ts#RESERVED_NAMESPACES` | | Kernel definitions | All `sys_*` objects are in fact **defined centrally in `platform-objects`**, even though `plugin-auth`/`service-job`/`service-settings` manifests each declare `namespace:'sys'` — ownership *declaration* is split from *definition*. | `platform-objects/src/**` | -| Boundary enforcement | "Apps may reference `sys_*` but never define them" is **documented intent only** — no validator enforces it; the `sys_` check only *exempts*, it does not *forbid*. | `manifest.zod.ts:66-70` | +| Boundary enforcement | "Apps may reference `sys_*` but never define them" is **documented intent only** — no validator enforces it; the `sys_` check only *exempts*, it does not *forbid*. | `packages/spec/src/kernel/manifest.zod.ts` | | Kernel cross-refs | ~60 lookup fields across identity/audit/security/metadata/system (and `service-ai`'s `ai_conversations.user_id`) point at the **hub objects `sys_user` / `sys_organization`**. | scan, see §"Why unified" | ### How mainstream metadata/low-code platforms name things @@ -240,7 +240,7 @@ reference target. It is: packages claiming the same namespace** — already modeled by `NamespaceConflictError`. Catches binary artifacts that bypass `defineStack`. - **Runtime registries** unify their duplicate semantics: the connector registry - stops silently overwriting (`engine.ts:441`) and uses the same conflict policy + stops silently overwriting (`packages/services/service-automation/src/engine.ts`) and uses the same conflict policy as objects/routes. --- diff --git a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md index 066880a140..5516d832b6 100644 --- a/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md +++ b/docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md @@ -52,12 +52,12 @@ The codebase scan found the kernel is a monolith with split ownership: | Finding | Evidence | |:--|:--| | **All `sys_*` objects are defined in `platform-objects`** — identity, audit, security, metadata, system domains. | `platform-objects/src/{identity,audit,security,metadata,system}/**` | -| Plugins **declare** `namespace:'sys'`, `scope:'system'` but **define no objects** — the data model lives in `platform-objects`. | `plugin-auth/src/manifest.ts:58-67`; `service-job`, `service-settings` manifests | -| `sys` is a **shared** namespace co-claimed by ~14 packages with no arbiter at the namespace level. | `objectql/src/registry.ts:346-389` (`namespaceRegistry: Map>`) | -| The `own`/`extend` ownership model is fully implemented: **one owner per object** (second `own` throws), extenders merge by `priority` (owner 100, extender 200). **No package extends a `sys_` object today.** | `objectql/src/registry.ts:406-518`; `object.zod.ts:856-897` | +| Plugins **declare** `namespace:'sys'`, `scope:'system'` but **define no objects** — the data model lives in `platform-objects`. | `packages/plugins/plugin-auth/src/manifest.ts#namespace`; `service-job`, `service-settings` manifests | +| `sys` is a **shared** namespace co-claimed by ~14 packages with no arbiter at the namespace level. | `packages/objectql/src/registry.ts#namespaceRegistry` (`namespaceRegistry: Map>`) | +| The `own`/`extend` ownership model is fully implemented: **one owner per object** (second `own` throws), extenders merge by `priority` (owner 100, extender 200). **No package extends a `sys_` object today.** | `packages/objectql/src/registry.ts`; `packages/spec/src/data/object.zod.ts` | | ~60 lookup fields converge on the **hub objects `sys_user` / `sys_organization`**, referenced from every domain (incl. `service-ai`'s `ai_conversations.user_id`). | scan | -| `RESERVED_NAMESPACES = {'base','system'}` — `sys` is **not** reserved. "Apps may reference but never define `sys_*`" is documented intent with **no enforcing validator**. | `registry.ts:13`; `manifest.zod.ts:66-70` | -| `scope: cloud\|system\|project` and `managedBy: platform\|config\|system\|append-only\|better-auth` already mark system data. | `manifest.zod.ts:133`; `object.zod.ts:354-385` | +| `RESERVED_NAMESPACES = {'base','system'}` — `sys` is **not** reserved. "Apps may reference but never define `sys_*`" is documented intent with **no enforcing validator**. | `packages/objectql/src/registry.ts#RESERVED_NAMESPACES`; `packages/spec/src/kernel/manifest.zod.ts` | +| `scope: cloud\|system\|project` and `managedBy: platform\|config\|system\|append-only\|better-auth` already mark system data. | `packages/spec/src/kernel/manifest.zod.ts`; `packages/spec/src/data/object.zod.ts#managedBy` | | The **`setup` admin app is a static monolith** that hard-references every `sys_*` object as nav entries — and its own comment notes it was made static *because* the objects were centralized (the older `@objectstack/plugin-setup` that assembled it at runtime was deleted). | `platform-objects/src/apps/setup.app.ts` | | `manifest.contributes.menus` exists in the schema but is **consumed nowhere** — a vestigial, unimplemented contribution point. No app-navigation merge / `appExtensions` analog to `objectExtensions` exists. | `manifest.zod.ts` (`contributes.menus`); no consumer found | @@ -350,20 +350,20 @@ is unchanged. What changes is that a tenant overlay stops *borrowing* that slot. ### 1. What the code does today, measured An object's registry entry is a list of `ObjectContributor`s keyed by name -(`packages/objectql/src/registry.ts:38-44`), and `registerObject` accepts exactly -two kinds — `own` and `extend` (`:1070-1075`, the vocabulary at -`packages/spec/src/data/object.zod.ts:2435`). +(`packages/objectql/src/registry.ts#registerObject`), and `registerObject` accepts exactly +two kinds — `own` and `extend` (`packages/objectql/src/registry.ts`, the vocabulary at +`packages/spec/src/data/object.zod.ts`). A tenant overlay of an `object` reaches that verb through two seams, and both pass **two arguments only**, so `ownership` takes its default `'own'`: | seam | call | when | |:--|:--|:--| -| `applyObjectRegistryMutation` | `packages/metadata-protocol/src/protocol.ts:7897-7911` | every `saveMetaItem` write-through | -| `loadMetaFromDb` | `packages/metadata-protocol/src/protocol.ts:11670-11673` | **every boot**, no authorization gate | +| `applyObjectRegistryMutation` | `packages/metadata-protocol/src/protocol.ts#applyObjectRegistryMutation` | every `saveMetaItem` write-through | +| `loadMetaFromDb` | `packages/metadata-protocol/src/protocol.ts#loadMetaFromDb` | **every boot**, no authorization gate | When the row's `package_id` equals the packaged owner's id, `registerObject` -takes the re-registration branch at `registry.ts:1157-1160` and **splices the +takes the re-registration branch at `packages/objectql/src/registry.ts#registerObject` and **splices the packaged contributor out of the list**. The packaged definition is *destroyed at write time* — it is not shadowed, and no second copy exists anywhere in the registry. Four consequences follow, all measured end-to-end in the 08:12Z report @@ -375,24 +375,24 @@ on #6853 (P0-P6) and re-read in the source here: 2. **It re-happens on every boot** (P6), through `loadMetaFromDb`, silently: `{"loaded":1,"errors":0,"invalid":0}` with no warning. 3. **`isArtifactBacked` starts lying.** It asks `getArtifactItem('object', …)` - (`protocol.ts:7535-7540`), whose object branch resolves the **merged** object - and rejects it when `_provenance === 'org'` (`registry.ts:1727-1733`, predicate - at `:844-846`). Since the overlay is now the *owner*, the merged body carries + (`packages/metadata-protocol/src/protocol.ts#isArtifactBacked`), whose object branch resolves the **merged** object + and rejects it when `_provenance === 'org'` (`packages/objectql/src/registry.ts`, predicate + at `packages/objectql/src/registry.ts`). Since the overlay is now the *owner*, the merged body carries the tenant's provenance, so the predicate answers `false` for a name a code package still ships. 4. **Two gates that read that predicate silently disarm.** `saveMetaItem`'s - two-tier gate (`protocol.ts:8460-8472`) stops refusing — `object` declares + two-tier gate (`packages/metadata-protocol/src/protocol.ts`) stops refusing — `object` declares `allowOrgOverride: false, allowRuntimeCreate: true` - (`packages/spec/src/kernel/metadata-plugin.zod.ts:628`), so the *first* write + (`packages/spec/src/kernel/metadata-plugin.zod.ts#allowOrgOverride`), so the *first* write is refused and every later one is admitted through the wrong tier. And tier 3 - of `restoreArtifactRegistryView` (`protocol.ts:8259-8272`), whose comment says + of `restoreArtifactRegistryView` (`packages/metadata-protocol/src/protocol.ts#restoreArtifactRegistryView`), whose comment says in as many words that it never retires a code-shipped object, fires: `objectContributors` goes empty, `getObject` answers `null`, and data CRUD 404s on a table the package still ships, until the process restarts (P3/P6). A fifth shape belongs to the same mechanism. When the row's `package_id` -**differs** from the packaged owner's, the ownership rule at `:1149-1155` throws, -the throw is caught and `console.warn`-ed at `protocol.ts:7906-7910`, and +**differs** from the packaged owner's, the ownership rule at `#package_id` throws, +the throw is caught and `console.warn`-ed at `packages/metadata-protocol/src/protocol.ts`, and `saveMetaItem` still reports success — the write-side silent discard filed as [#6995](https://github.com/objectstack-ai/objectstack/issues/6995), which the ruling requires this model to answer (§ D9.6). @@ -407,13 +407,13 @@ layer's body as a replay of the same one. The borrowed slot carries authority the overlay measurably never uses: - **It claims no namespace.** `registerObject` calls `registerNamespace` only when - a namespace argument is passed (`:1136-1138`); both overlay seams pass none, so + a namespace argument is passed (`packages/metadata-protocol/src/protocol.ts`); both overlay seams pass none, so `namespaceRegistry` keeps the package's entry. What the splice *does* destroy is the owner contributor's own `namespace` field (measured `''` where the package had `'myapp'`, P5) — inert today only because `computeFQN` is identity - (`:61-63`). + (`#packageId`). - **It does not decide package membership.** `getAllObjects(packageId)` matches - *any* contribution's `packageId` (`:1307`), owner or not. + *any* contribution's `packageId` (`#packageId`), owner or not. - **It owns no table.** The physical table is the packaged owner's, created by its schema sync; an overlay's new fields ride ADR-0045 additive materialization. @@ -427,20 +427,20 @@ question, and D9 answers it as one. #### D9.1 — a third contributor kind, loader-set and never authorable `ObjectContributor.ownership` gains `'overlay'`, and the vocabulary -`ObjectOwnershipEnum` (`packages/spec/src/data/object.zod.ts:2435`) gains it too +`ObjectOwnershipEnum` (`packages/spec/src/data/object.zod.ts#ObjectOwnershipEnum`) gains it too — one vocabulary, not a parallel list (Prime Directive #8). That enum is **loader-facing, not author-facing**, and this amendment binds it to stay that way. Measured: `ObjectOwnershipEnum` has no runtime consumer at all in this repo (`registry.ts` imports only the `ObjectOwnership` *type*), and the two existing kinds are set at exactly three call sites — `own` by the package loader -(`packages/objectql/src/engine.ts:2920`, `:2933`, `:3147`, `:3157`) and `extend` -by the `objectExtensions` loop (`engine.ts:2956`, priority from the manifest +(`packages/objectql/src/engine.ts`, `packages/objectql/src/engine.ts`, `packages/objectql/src/engine.ts`, `packages/objectql/src/engine.ts`) and `extend` +by the `objectExtensions` loop (`packages/objectql/src/engine.ts`, priority from the manifest entry). No author ever writes `ownership: 'own'`; a package author declares `objectExtensions: [{ extend: '…' }]` and the loader picks the kind. `'overlay'` is therefore set by the two hydration seams and by nothing else, and the enum's docblock must say so alongside the existing warning that separates it from the -record-`ownership` model (`object.zod.ts:1385-1392`). +record-`ownership` model (`packages/spec/src/data/object.zod.ts#ownership`). This is the "hard to get wrong" property doing real work: the new kind adds **no authoring surface**, so no hand-written or AI-written metadata can reach for it, @@ -448,7 +448,7 @@ correctly or otherwise. #### D9.2 — resolution: the overlay replaces the BASE layer; extenders still fold on top -`resolveObject` (`registry.ts:1206-1234`) selects its base layer as +`resolveObject` (`packages/objectql/src/registry.ts#resolveObject`) selects its base layer as `overlay ?? owner` instead of `owner`, then folds `extend` contributions exactly as it does today. @@ -463,8 +463,8 @@ That is the argument for replace-semantics over the two alternatives, and it is the one the ruling asked for: - **Overlay as `extend` is refused.** `mergeObjectDefinitions` merges fields - additively (`registry.ts:86-108`; `merged.fields = { ...base.fields, - ...extension.fields }` at `:91`) and has no expression for *removal* at all. + additively (`packages/objectql/src/registry.ts#mergeObjectDefinitions`; `merged.fields = { ...base.fields, + ...extension.fields }` at `packages/objectql/src/registry.ts`) and has no expression for *removal* at all. An overlay that drops a packaged field would silently stop dropping it — the measured overlay body carried `overlay_only` and neither `amount` nor `packaged_only`; as an extender the resolved object would carry all three. @@ -536,21 +536,21 @@ Binding consequences: #### D9.3 — selection is by KIND; priority stays descriptive -`contributors.sort((a, b) => a.priority - b.priority)` (`:1189`) totals the whole +`contributors.sort((a, b) => a.priority - b.priority)` (`#contributors`) totals the whole list, so the overlay needs a priority for deterministic ordering: `DEFAULT_OVERLAY_PRIORITY = 150`, between `DEFAULT_OWNER_PRIORITY = 100` and -`DEFAULT_EXTENDER_PRIORITY = 200` (`:31-32`), so a `getObjectContributors()` read +`DEFAULT_EXTENDER_PRIORITY = 200` (`#getObjectContributors`), so a `getObjectContributors()` read lists the stack in layer order. It is **not** the selection rule. Base selection asks the kind, never "highest priority wins", because extender priority is author-declared (`ext.priority ?? 200`, -`engine.ts:2944`) and a package could otherwise re-rank a tenant's overlay by +`packages/objectql/src/engine.ts`) and a package could otherwise re-rank a tenant's overlay by declaring `priority: 140`. Declared numbers order *peers*; they must not be able to change *which layer is the base*. #### D9.4 — `computeFQN` is untouched, and the namespace loss is repaired for free -`computeFQN` is identity (`:61-63`); the overlay layer shares the owner's key and +`computeFQN` is identity (`packages/objectql/src/engine.ts`); the overlay layer shares the owner's key and this amendment introduces **no namespace dimension** — an overlay is a layer over one object name, not a second object. The measured namespace loss (P5) is not fixed by a rule but by subtraction: the packaged owner's contributor is no longer @@ -560,7 +560,7 @@ overlay slot per object, unchanged. #### D9.5 — `assertSingleOwnerPerObject` is unchanged, and gains one violation class -`assertSingleOwnerPerObject` (`:1355-1379`) counts `ownership === 'own'`. Overlays +`assertSingleOwnerPerObject` (`packages/objectql/src/engine.ts`) counts `ownership === 'own'`. Overlays are not owners, so it keeps reading exactly one owner per object name — literally the D3 sentence, with no exemption list. This matters beyond tidiness: ADR-0028's D5/D6 (reserved `sys`, single-owner-per-object, apps-cannot-define-kernel) @@ -581,7 +581,7 @@ This is the clause that makes `isArtifactBacked` stop lying, and it is **not** implied by D9.2 — it has to be decided, because D9.2 deliberately leaves the merged body identical, `_provenance: 'org'` included. -`getArtifactItem(type, name)`'s object branch (`registry.ts:1727-1733`) currently +`getArtifactItem(type, name)`'s object branch (`packages/objectql/src/registry.ts#getArtifactItem`) currently resolves `getObject(name)` — the merged body — and applies the `_packageId`/`isTenantAuthored` test to it. Under D9 it applies that test to the **owner contributor's definition**. Consequences: @@ -602,7 +602,7 @@ evidence it admits every subsequent write through the `allowRuntimeCreate` tier. So this is the declared contract being enforced consistently, not a new restriction; but a deployment that has been living in the post-first-write state will see writes start being refused. The documented operator hatch -(`OS_METADATA_WRITABLE=object`, `protocol.ts:7380`) is the same one door as +(`OS_METADATA_WRITABLE=object`, `packages/metadata-protocol/src/protocol.ts#OS_METADATA_WRITABLE`) is the same one door as before, and it now has to stay open for the *life* of the customization rather than only for its first save. Deployments that cannot accept that must move the customization into a package — which is the position ADR-0005's whitelist has @@ -621,11 +621,11 @@ always taken for `object`. moment the information is still in hand — **write time, where the packaged owner is one lookup away — instead of delete time, where it has been destroyed.** -- **Removing the object** (`unregisterObject`, `:1482-1520`) keeps its ADR-0029 +- **Removing the object** (`unregisterObject`, `packages/metadata-protocol/src/protocol.ts`) keeps its ADR-0029 extender guard verbatim. Tier 3 of `restoreArtifactRegistryView` then reads: a packaged `own` survives → remove the overlay layer only; no packaged owner → remove the entry, as today. -- **Uninstalling the owning package** (`unregisterObjectsByPackage`, `:1385-1415`) +- **Uninstalling the owning package** (`unregisterObjectsByPackage`, `packages/metadata-protocol/src/protocol.ts`) takes the object's overlay layer with the owner it layers over. Nothing durable is lost: the layer is a runtime projection of a `sys_metadata` row that is not touched, and a re-install re-hydrates it. D9.5's orphan violation is the @@ -648,7 +648,7 @@ Both seams — `applyObjectRegistryMutation` and `loadMetaFromDb` — sit **afte package registration in the real boot order (measured in P6), so the lookup is answerable. The same discriminator is owed to the two metadata-service ingest paths that also register `'own'` from a reloaded body -(`packages/objectql/src/plugin.ts:688-693`, `:765-770`); left alone they re-open +(`packages/objectql/src/plugin.ts`, `packages/objectql/src/plugin.ts`); left alone they re-open the splice through a third door. #### D9.9 — #6995: the row's `package_id` is provenance on the layer, never an ownership claim @@ -662,7 +662,7 @@ binding `P` against the packaged owner's id `O`: |:--|:--| | `P == O` | the normal case — one overlay layer over `O`'s object. | | `P` empty / absent (the `'sys_metadata'` sentinel) | **accepted.** A package-less env-wide overlay is ADR-0005's platform-global shape; the row addresses the object by name and the registry knows who owns it. Today this throws (measured, P2) — that refusal was an artefact of the borrowed slot, not a decision. | -| `P == Q`, some other package | **refused at the producer, loudly.** On the write path `saveMetaItem` returns an error (an ADR-0112-registered code minted by the implementation card) instead of a success receipt; at boot the row is not layered and is counted in `loadMetaFromDb`'s per-record `errors` with its reason — which that seam already does today (`protocol.ts:11706-11708`), and which is why #6995 is a **write-path** divergence and not a boot-path one. | +| `P == Q`, some other package | **refused at the producer, loudly.** On the write path `saveMetaItem` returns an error (an ADR-0112-registered code minted by the implementation card) instead of a success receipt; at boot the row is not layered and is counted in `loadMetaFromDb`'s per-record `errors` with its reason — which that seam already does today (`packages/metadata-protocol/src/protocol.ts#saveMetaItem`), and which is why #6995 is a **write-path** divergence and not a boot-path one. | The last row is a real decision, not bookkeeping, and the reason is a store asymmetry worth recording: the overlay-uniqueness index keys on @@ -685,9 +685,9 @@ The ruling named the alternative as "a new ownership kind" — a contributor tha *also* owns, with `assertSingleOwnerPerObject` taught to accept two. Rejected: - **It grants authority nothing consumes.** § 2 measured what ownership carries - (namespace registration, the `_packageId` stamp at `:1314`, the table) and the + (namespace registration, the `_packageId` stamp at `packages/metadata-protocol/src/protocol.ts`, the table) and the overlay uses none of it. -- **It makes D3 conditional.** Every consumer of `getObjectOwner` (`:1332-1335`) +- **It makes D3 conditional.** Every consumer of `getObjectOwner` (`packages/metadata-protocol/src/protocol.ts`) would have to be re-read to decide *which* owner it means, and ADR-0028's D5/D6 rest on D3 being unconditional. An assertion with an exemption clause is an assertion that has to be re-litigated at every call site. @@ -705,25 +705,25 @@ does to it: | site | today | under D9 | |:--|:--|:--| -| `registry.ts:1075` default priority | `own ? 100 : 200` | third arm, `DEFAULT_OVERLAY_PRIORITY = 150` | -| `registry.ts:1116-1130` `provisionPrimary` / `provisionSearchCompanion` | gated on `own` | **gate becomes "is this a BASE layer" (`own` or `overlay`)**. Missing this is a silent regression: the overlay body *is* the resolved base, so skipping title provisioning would change `nameField` on every overlaid object. | -| `:1148-1155` second-owner throw | overlay hits it (#6995) | unreachable from the overlay seams | -| `:1157-1160` same-package `own` splice | **destroys the packaged body** | untouched; the overlay never enters this branch | -| `:1167-1170` same-package `extend` splice | — | mirrored for `overlay`: at most one layer, replaced on re-write | -| `:1217` base selection | `find(own)` | `find(overlay) ?? find(own)` | -| `:1228` extender fold | folds `extend` | unchanged | -| `:1314` `getAllObjects` `_packageId` stamp | the overlay's id | the packaged owner's id (the same value whenever `P == O`) | -| `:1332-1335` `getObjectOwner` | — | unchanged; keeps meaning "the package that owns the table" | -| `:1355-1379` `assertSingleOwnerPerObject` | — | unchanged + orphan-overlay class (D9.5) | -| `:1385-1415` `unregisterObjectsByPackage` | — | overlay layer leaves with its base (D9.7) | -| `:1482-1520` `unregisterObject` | — | extender guard unchanged; tier 3 calls the layer-addressed verb first | -| `:1727-1733` `getArtifactItem` object branch | reads the merged body | reads the owner contributor (D9.6) | -| `spec/data/object.zod.ts:2435` | `['own','extend']` | third value + the "loader-set, never authored" clause | -| `protocol.ts:7897-7911`, `:11670-11673` | register `'own'` | D9.8 discriminator | -| `objectql/src/plugin.ts:688`, `:765` | register `'own'` | D9.8 discriminator | - -`ObjectContributor` is exported (`packages/objectql/src/index.ts:28`, -`core.ts:24`), so the widened union is a public type change for `objectui` / +| `packages/objectql/src/registry.ts#DEFAULT_OVERLAY_PRIORITY` default priority | `own ? 100 : 200` | third arm, `DEFAULT_OVERLAY_PRIORITY = 150` | +| `packages/objectql/src/registry.ts` `provisionPrimary` / `provisionSearchCompanion` | gated on `own` | **gate becomes "is this a BASE layer" (`own` or `overlay`)**. Missing this is a silent regression: the overlay body *is* the resolved base, so skipping title provisioning would change `nameField` on every overlaid object. | +| `packages/objectql/src/registry.ts` second-owner throw | overlay hits it (#6995) | unreachable from the overlay seams | +| `packages/objectql/src/registry.ts` same-package `own` splice | **destroys the packaged body** | untouched; the overlay never enters this branch | +| `packages/objectql/src/registry.ts` same-package `extend` splice | — | mirrored for `overlay`: at most one layer, replaced on re-write | +| `packages/objectql/src/registry.ts` base selection | `find(own)` | `find(overlay) ?? find(own)` | +| `packages/objectql/src/registry.ts` extender fold | folds `extend` | unchanged | +| `#getAllObjects` `getAllObjects` `_packageId` stamp | the overlay's id | the packaged owner's id (the same value whenever `P == O`) | +| `#getObjectOwner` `getObjectOwner` | — | unchanged; keeps meaning "the package that owns the table" | +| `#assertSingleOwnerPerObject` `assertSingleOwnerPerObject` | — | unchanged + orphan-overlay class (D9.5) | +| `#unregisterObjectsByPackage` `unregisterObjectsByPackage` | — | overlay layer leaves with its base (D9.7) | +| `#unregisterObject` `unregisterObject` | — | extender guard unchanged; tier 3 calls the layer-addressed verb first | +| `#getArtifactItem` `getArtifactItem` object branch | reads the merged body | reads the owner contributor (D9.6) | +| `packages/spec/src/data/object.zod.ts` | `['own','extend']` | third value + the "loader-set, never authored" clause | +| `packages/metadata-protocol/src/protocol.ts`, `packages/metadata-protocol/src/protocol.ts` | register `'own'` | D9.8 discriminator | +| `packages/objectql/src/plugin.ts`, `packages/objectql/src/plugin.ts` | register `'own'` | D9.8 discriminator | + +`ObjectContributor` is exported (`packages/objectql/src/index.ts`, +`packages/objectql/src/core.ts`), so the widened union is a public type change for `objectui` / `cloud` consumers and belongs in a minor with a changeset. ### 6. Deliberately left open for the implementation card @@ -769,7 +769,7 @@ does to it: - The honest gate refuses repeat overlay writes that today succeed (D9.6). This is the declared contract, and it is still a behaviour change a live deployment can feel. -- Two ingest paths (`plugin.ts:688`, `:765`) must adopt the discriminator or the +- Two ingest paths (`packages/objectql/src/plugin.ts`, `packages/objectql/src/plugin.ts`) must adopt the discriminator or the splice returns through a third door — a coupling that is easy to miss because those paths are about metadata-service reloads, not about tenant overlays. @@ -785,8 +785,8 @@ does to it: `packages/objectql/src/registry.ts` is registered against ADR-0029 in `scripts/adr-anchors.json` by this change. It was **unanchored**, which is the recurrence shape Prime Directive #13 names and the same one #6825's amendment -found for ADR-0005: the file that implements D3 (the `own` splice at `:1157-1160`, -`assertSingleOwnerPerObject` at `:1355`, the extender guard at `:1490`) never said +found for ADR-0005: the file that implements D3 (the `own` splice at `packages/objectql/src/registry.ts`, +`assertSingleOwnerPerObject` at `#assertSingleOwnerPerObject`, the extender guard at) never said which decision an author editing it was standing on — so the splice could be read as an ordinary re-registration convenience, which is precisely how it came to destroy a packaged definition. diff --git a/docs/adr/0032-unified-expression-layer.md b/docs/adr/0032-unified-expression-layer.md index ffb0bb590d..ac1b3a3a6a 100644 --- a/docs/adr/0032-unified-expression-layer.md +++ b/docs/adr/0032-unified-expression-layer.md @@ -40,8 +40,8 @@ What the incident actually was — established by reproducing it end-to-end agai Supporting facts (verified in source): -- **Loose contract + coercion.** `ExpressionInputSchema` (`spec/shared/expression.zod.ts:84`) transforms any bare string into `{dialect:'cel', source}`; `flow.zod.ts` only *consumes* it. A `condition: string` is silently treated as CEL with no opportunity to reject a bad form. -- **The spec teaches the bad form.** `automation/flow.zod.ts:212-214`'s own `FlowSchema` JSDoc example uses `condition: "{amount} < 500"` / `"{amount} >= 500"` — the **exact single-brace-in-CEL pattern that silently fails**. This is the concrete answer to *"why did the AI write it wrong"*: not a missing skill — an actively wrong authoritative example the model faithfully copied. +- **Loose contract + coercion.** `ExpressionInputSchema` (`packages/spec/src/shared/expression.zod.ts#ExpressionInputSchema`) transforms any bare string into `{dialect:'cel', source}`; `flow.zod.ts` only *consumes* it. A `condition: string` is silently treated as CEL with no opportunity to reject a bad form. +- **The spec teaches the bad form.** `packages/spec/src/automation/flow.zod.ts#FlowSchema`'s own `FlowSchema` JSDoc example uses `condition: "{amount} < 500"` / `"{amount} >= 500"` — the **exact single-brace-in-CEL pattern that silently fails**. This is the concrete answer to *"why did the AI write it wrong"*: not a missing skill — an actively wrong authoritative example the model faithfully copied. - **Three syntaxes coexist, at scale.** Across `../templates` (10 packages, 30 flows): only **6** flow `condition`s, but **191** single-brace `{…}` template usages and **40+** double-brace `{{…}}` (titleFormat/notification). So by volume the dominant expression surface is *interpolation*, not predicates — and the single-brace delimiter is the one that collides with CEL. (Date helpers also split: template `TODAY()`/`NOW()` vs CEL `today()`/`daysFromNow(int)`.) - **Inconsistent failure policy.** The same evaluation-failure decision is made five different ways: `seed-loader` (loud fail), hook-wrappers (warn + false), rule-validator (warn + skip → null), the engine's formula projection (silent null), flow `evaluateCondition` (silent false). No single declared policy. @@ -81,7 +81,7 @@ Rationale (AI-first): the #1 LLM error is *mode over-generalization* — copying ### 4. Correctness is training data. -- **Fix the canon first.** Remove every anti-pattern from the spec's own JSDoc (`flow.zod.ts:212-214`'s `{amount} < 500`), skills, and guides **before** shipping the contract — the model emits what it is shown. +- **Fix the canon first.** Remove every anti-pattern from the spec's own JSDoc (`packages/spec/src/automation/flow.zod.ts`'s `{amount} < 500`), skills, and guides **before** shipping the contract — the model emits what it is shown. - **Ship a golden example set** per field role (predicate / template / computed value), copy-pasteable and correct, that authoring agents are pointed at. - **Make the contract self-describing** (Decision 1e) so the agent *discovers* the rule rather than inferring it from priors. @@ -121,9 +121,9 @@ Out of scope (separate surfaces, intentionally **not** unified): query-filter op ## Sequencing (roadmap, ordered by AI-safety ROI) -1. **Stop the silent failure + fix the canon (ship first, decisive).** One `EvalResult` policy: parse → build error, runtime fault → loud attributed failure; delete every `error→false/null` swallow (`evaluateCondition` + the other four). Simultaneously remove the anti-pattern examples from spec JSDoc/skills (`flow.zod.ts:212-214`). This alone kills the #1491/#1429 class. +1. **Stop the silent failure + fix the canon (ship first, decisive).** One `EvalResult` policy: parse → build error, runtime fault → loud attributed failure; delete every `error→false/null` swallow (`evaluateCondition` + the other four). Simultaneously remove the anti-pattern examples from spec JSDoc/skills (`packages/spec/src/automation/flow.zod.ts`). This alone kills the #1491/#1429 class. 2. **Build-time parse validation + error-message contract.** CLI/registration parses every expression; failures carry `file:line`, source, and the *corrective* message (1d). -3. **Contract/types.** `spec`: expression fields become typed (`Predicate` / `Template` / `Expr`); remove bare-string acceptance + the `shared/expression.zod.ts:84` coercion. Land `` cel`` `` / `` tpl`` `` builders. Two shapes, no single brace. +3. **Contract/types.** `spec`: expression fields become typed (`Predicate` / `Template` / `Expr`); remove bare-string acceptance + the `packages/spec/src/shared/expression.zod.ts#Predicate` coercion. Land `` cel`` `` / `` tpl`` `` builders. Two shapes, no single brace. 4. **Schema-aware validation (v1)** — project resolved object schema into the CEL type env; field-existence + bool-return. (v2: full type inference.) 5. **Agent tooling** — expose `validate_expression` + field-dialect/scope introspection; feed schema into the authoring context. 6. **Template engine** — `{{ }}` holes (paths + formatter whitelist) with defined value→string semantics; delete the single-brace resolver; unify date helpers under CEL stdlib. diff --git a/docs/adr/0049-no-unenforced-security-properties.md b/docs/adr/0049-no-unenforced-security-properties.md index 1f274f834e..921bf39bc3 100644 --- a/docs/adr/0049-no-unenforced-security-properties.md +++ b/docs/adr/0049-no-unenforced-security-properties.md @@ -1,6 +1,6 @@ # ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) -**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`permission-evaluator.ts:37,122`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. +**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) **Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). @@ -35,7 +35,7 @@ Shipping a security property in a fourth state — *parsed, unmarked, unenforced A second, roadmap-independent defect compounds the first: `PermissionEvaluator` **fails open** for operations it doesn't recognise -(`permission-evaluator.ts:35`, `if (!permKey) return true`). Any future +(`packages/plugins/plugin-security/src/permission-evaluator.ts#permKey`, `if (!permKey) return true`). Any future destructive operation added without registering it in `OPERATION_TO_PERMISSION` is silently ungated. The evaluator must **fail closed** for the destructive operation class. @@ -46,13 +46,13 @@ operation class. - Evidence: `docs/audits/2026-06-security-identity-property-liveness.md` and the cross-type synthesis in `docs/audits/README.md` (cluster #1). -- The CRUD path *is* enforced: `SecurityPlugin` (`security-plugin.ts:326`) +- The CRUD path *is* enforced: `SecurityPlugin` (`packages/plugins/plugin-security/src/security-plugin.ts#SecurityPlugin`) resolves permission sets and calls `PermissionEvaluator.checkObjectPermission`, which maps the ObjectQL operation to an `ObjectPermission` key via - `OPERATION_TO_PERMISSION` (`permission-evaluator.ts:8-16`). + `OPERATION_TO_PERMISSION` (`packages/plugins/plugin-security/src/permission-evaluator.ts#OPERATION_TO_PERMISSION`). - That map covers only `find/findOne/count/aggregate/insert/update/delete`. The three destructive permission bits in the spec - (`permission.zod.ts:28-30` — `allowTransfer`/`allowRestore`/`allowPurge`) + (`packages/spec/src/security/permission.zod.ts#allowTransfer` — `allowTransfer`/`allowRestore`/`allowPurge`) have **no operation pointing at them**, and the operations they describe (`transfer`/`restore`/`purge`) **do not yet exist** as ObjectQL operations. So the bits are dangling, and the `if (!permKey) return true` default means diff --git a/docs/adr/0052-audit-is-not-the-activity-feed.md b/docs/adr/0052-audit-is-not-the-activity-feed.md index 6af69eeb6a..0fc5e94918 100644 --- a/docs/adr/0052-audit-is-not-the-activity-feed.md +++ b/docs/adr/0052-audit-is-not-the-activity-feed.md @@ -16,7 +16,7 @@ and *"SOC 2, HIPAA, GDPR"* currently **registers five objects across five bounded contexts**: ```ts -// packages/plugins/plugin-audit/src/audit-plugin.ts:40 +// packages/plugins/plugin-audit/src/audit-plugin.ts objects: [SysAuditLog, SysActivity, SysComment, SysAttachment, SysNotification] ``` @@ -71,7 +71,7 @@ default-loaded, it became the path of least resistance for anything and a denormalized activity row: ```ts - // packages/plugins/plugin-audit/src/audit-writers.ts:328-329 + // packages/plugins/plugin-audit/src/audit-writers.ts#auditRow await sys.object('sys_audit_log').create(auditRow); await sys.object('sys_activity').create(activityRow); ``` @@ -79,7 +79,7 @@ default-loaded, it became the path of least resistance for anything The two objects are 80% the same event at different fidelities — `sys_audit_log` is *"immutable, compliance-grade"*, `sys_activity` is *"denormalized, human-readable summaries shown [in the UI]"* - (`audit-writers.ts:43-45`). + (`packages/plugins/plugin-audit/src/audit-writers.ts`). - **Homeless objects parked in the audit manifest.** `sys_comment` is *defined* by `plugin-audit` but **written by the UI directly** (the audit writer never @@ -88,7 +88,7 @@ default-loaded, it became the path of least resistance for anything way, and the code **says so**: ```ts - // packages/plugins/plugin-audit/src/audit-plugin.ts:6-11 + // packages/plugins/plugin-audit/src/audit-plugin.ts // Registered here but still owned by platform-objects (the plugin contributes // - sys_attachment — a file↔record link belonging with service-storage's … // - sys_notification — … belonging with messaging @@ -102,9 +102,9 @@ default-loaded, it became the path of least resistance for anything - **Runtime cross-context coupling.** The same writer lazily resolves the messaging service to emit collaboration notifications - (`audit-plugin.ts:96-99`), and a regression test exists specifically to stop + (`packages/plugins/plugin-audit/src/audit-plugin.ts`), and a regression test exists specifically to stop the audit writer from auditing its own writes - (`audit-writers.test.ts:9`) — evidence the coupling already bites. + (`packages/plugins/plugin-audit/src/audit-writers.test.ts`) — evidence the coupling already bites. ## 2. The design flaws (what we are correcting) @@ -155,7 +155,7 @@ needs). Audit no longer dual-writes. `sys_audit_log` is the only object whose contract is *governance*: every field `readonly: true`, `managedBy: 'append-only'` -(`sys-audit-log.object.ts:22`), retention policy, security-gated read. Per +(`packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts#managedBy`), retention policy, security-gated read. Per ADR-0049 (enforce-or-remove), those properties must be enforced at audit's own boundary — which is only possible once mutable co-tenants (`sys_comment`) leave. Audit **remains a default platform capability**: compliance is foundational, diff --git a/docs/adr/0053-date-and-datetime-semantics.md b/docs/adr/0053-date-and-datetime-semantics.md index 9c3d775e60..b8f200dfcf 100644 --- a/docs/adr/0053-date-and-datetime-semantics.md +++ b/docs/adr/0053-date-and-datetime-semantics.md @@ -16,9 +16,9 @@ timestamp** — an *instant*. That is the textbook "date-as-instant" mistake, an it produces two failures: 1. **Silent equality miss.** The write path stores the full timestamp - (`formatInput` does not normalize — `sql-driver.ts:1967`), but the filter path + (`formatInput` does not normalize — `packages/drivers/driver-sql/src/sql-driver.ts#formatInput`), but the filter path normalizes the query value to `YYYY-MM-DD` (`coerceFilterValue` — - `sql-driver.ts:1543`). So `date == ` compares + `packages/drivers/driver-sql/src/sql-driver.ts#coerceFilterValue`). So `date == ` compares `"2026-08-15T17:24Z"` against `"2026-08-15"` → never equal. Range filters (`$gte`/`$lt`) only work by accident of lexicographic ISO ordering. 2. **Off-by-one across timezones.** A date stored as UTC-midnight @@ -27,8 +27,8 @@ it produces two failures: timezone-naive string and never converting it to an instant. A third, related defect: `daysFromNow(n)`/`daysAgo(n)` keep the current -**wall-clock time** (`addDaysUtc` — `stdlib.ts:36`), unlike `today()` which -truncates to UTC midnight (`startOfDayUtc` — `stdlib.ts:19`). And `today()` +**wall-clock time** (`addDaysUtc` — `packages/formula/src/stdlib.ts#addDaysUtc`), unlike `today()` which +truncates to UTC midnight (`startOfDayUtc` — `packages/formula/src/stdlib.ts#startOfDayUtc`). And `today()` is computed in **UTC**, not the user/org timezone, even though a `sys-user-preference.timezone` exists but is never read by the engine. @@ -54,10 +54,10 @@ is computed in **UTC**, not the user/org timezone, even though a | Layer | Treatment of `Field.date` | Evidence | |-------|---------------------------|----------| -| Column | `date` → `table.date()` (SQLite has no real DATE type — TEXT affinity) | `sql-driver.ts:1816` | -| **Write** | **no normalization** — the JS `Date` is stored verbatim, keeping its time | `formatInput`, `sql-driver.ts:1967` | +| Column | `date` → `table.date()` (SQLite has no real DATE type — TEXT affinity) | `packages/drivers/driver-sql/src/sql-driver.ts` | +| **Write** | **no normalization** — the JS `Date` is stored verbatim, keeping its time | `formatInput`, `packages/drivers/driver-sql/src/sql-driver.ts#formatInput` | | Read | no normalization — returns the stored string with its time | empirical: `dev.db` holds `"2026-07-15T17:24:56.533Z"` | -| **Filter** | **normalizes the query value to `YYYY-MM-DD`** (date-only string compare) | `coerceFilterValue`, `sql-driver.ts:1543-1554` | +| **Filter** | **normalizes the query value to `YYYY-MM-DD`** (date-only string compare) | `coerceFilterValue`, `packages/drivers/driver-sql/src/sql-driver.ts#coerceFilterValue` | | Formula | the stored string is hydrated to a `Date` (date-only → UTC midnight) and compared against the time-function `Date` | `applyFormulaPlan` (`engine.ts`), `hydrateOverloadStrings` (`cel-engine.ts`) | The write/filter mismatch is the proximate cause: the filter layer already @@ -65,9 +65,9 @@ The write/filter mismatch is the proximate cause: the filter layer already ### The time functions disagree with each other -- `today()` → start-of-day **UTC** (`startOfDayUtc`, `stdlib.ts:19,57`). +- `today()` → start-of-day **UTC** (`startOfDayUtc`, `packages/formula/src/stdlib.ts#startOfDayUtc`). - `daysFromNow(n)`/`daysAgo(n)` → `now() ± n*24h`, **keeping wall-clock time** - (`addDaysUtc`, `stdlib.ts:36`). Two calls a minute apart differ. + (`addDaysUtc`, `packages/formula/src/stdlib.ts#addDaysUtc`). Two calls a minute apart differ. - CEL's only temporal type is `google.protobuf.Timestamp` (a UTC instant) — there is no `PlainDate`. So a date field flowing into CEL is forced into an instant, which is exactly what we want to avoid. @@ -107,7 +107,7 @@ between them is precisely "does this concept depend on a timezone?" lexicographically = chronologically, so range comparisons stay correct. `today()`/`daysFromNow()` used against a date field are compared date-only. 4. **No change to `Field.datetime`** — it keeps full-instant semantics - (`datetimeFields`, stored as UTC ms — `sql-driver.ts:1500`). + (`datetimeFields`, stored as UTC ms — `packages/drivers/driver-sql/src/sql-driver.ts#datetimeFields`). After Phase 1, `date == daysFromNow(n)` works (both sides are the same calendar day), `$in` of dates works, and the day-window range pattern keeps working. The @@ -262,7 +262,7 @@ consistent with all three boundaries (CEL hydration, driver filter, Phase-1 stor > UTC-midnight-hydrated field — the silent-miss bug returns. Bonus: making `daysFromNow(n)`/`daysAgo(n)` compute `calendarDay ± n → UTC-midnight` -**also fixes the "keeps wall-clock time" defect** (`stdlib.ts:36`) for free. The +**also fixes the "keeps wall-clock time" defect** (`packages/formula/src/stdlib.ts#addDaysUtc`) for free. The `now() + duration("Nh")` escape hatch remains for genuine sub-day instants. **D2 — tz-aware analytics buckets in-memory (JS), uniformly; do not emit @@ -346,14 +346,14 @@ paths are byte-for-byte today's behavior — the safe default and the rollback t ADR-0053 fixed the `date`-as-string-vs-instant family (#1874) on the driver CRUD path, and Phase 1 explicitly left `Field.datetime` stored as UTC epoch ms -(`sql-driver.ts:1500`, decision step 4). But analytics has a **second filter +(`packages/drivers/driver-sql/src/sql-driver.ts#datetimeFields`, decision step 4). But analytics has a **second filter surface that never touches that coercion**: `NativeSQLStrategy` builds raw SQL and runs it via `engine.execute`, bypassing the driver's dialect-aware -`coerceFilterValue` (`sql-driver.ts:1543`). `buildFilterClause` emits +`coerceFilterValue` (`packages/drivers/driver-sql/src/sql-driver.ts#coerceFilterValue`). `buildFilterClause` emits `${col} $N` and binds the comparand directly -(`native-sql-strategy.ts:385-425`); the only type recovery was +(`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`); the only type recovery was `coerceFilterValueForSql`, which re-derives a type by **regex on the value's -shape** — no schema type, no date branch (`filter-normalizer.ts:127-140`). +shape** — no schema type, no date branch (`packages/services/service-analytics/src/strategies/filter-normalizer.ts`). So a dashboard relative-date token resolved to an ISO string (`"2025-06-18"`), filtered against a `Field.datetime` column stored as an INTEGER epoch on SQLite, @@ -373,8 +373,8 @@ reach. The first increment — commit `6f4cf856e` (branch `fix/analytics-datetime-epoch-filter`) — exposes the driver's coercion to analytics via a new `StrategyContext.coerceTemporalFilterValue(object, field, value)` hook delegating to the driver, applied across `gte/lte/gt/lt/equals`, -`in/notIn`, and the `dateRange`/timeDimension path (`native-sql-strategy.ts:371`, -`:88-106`). SQLite `datetime` → epoch ms; `date` text and native-timestamp +`in/notIn`, and the `dateRange`/timeDimension path (`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`, +`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`). SQLite `datetime` → epoch ms; `date` text and native-timestamp dialects (Postgres/MySQL) pass through unchanged. **Record this PR as ledger evidence** — the same enforce-resolution pattern D3 uses for the dead schedule fields. diff --git a/docs/adr/0055-master-detail-controlled-by-parent.md b/docs/adr/0055-master-detail-controlled-by-parent.md index 5c798494d1..03b01e6855 100644 --- a/docs/adr/0055-master-detail-controlled-by-parent.md +++ b/docs/adr/0055-master-detail-controlled-by-parent.md @@ -19,11 +19,11 @@ A **master-detail detail** record today is access-controlled entirely on its own ## Context — the mechanisms this builds on (verified) -1. **Read-path RLS injection.** `security-plugin.ts:481-495` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`security-plugin.ts:753-788`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`). -2. **Pre-resolved membership (§7.3.1) already exists.** The RLS compiler recognizes `field IN (current_user.)` and resolves `` against `ExecutionContext.rlsMembership` — "the runtime resolves set-membership that would otherwise need a subquery … and stages each set here under a stable key" (`execution-context.zod.ts:74-91`; merge at `rls-compiler.ts:79-99`). **This is the seam controlled_by_parent plugs into — no new compiler form.** -3. **By-id write pre-image check (#1994).** `security-plugin.ts:341-400` already re-reads the target row under the write-op RLS filter before an update/delete and denies if invisible. This is the exact hook to extend with a master-access check. -4. **`sharingModel` enforcement seam.** `sharing-service.ts:53-62` reads `object.sharingModel`; `buildReadFilter` (`:101-143`) gates on it (`effectiveSharingModel(schema) !== 'private'`). A `controlled_by_parent` baseline plugs in here / in the security middleware. -5. **Master-detail storage.** A `master_detail` field's **key is the FK column**; its `reference` (`field.zod.ts:386-400`) names the master object. Given a detail row, the master id is `row[masterFieldKey]`. +1. **Read-path RLS injection.** `packages/plugins/plugin-security/src/security-plugin.ts#rlsFilter` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`packages/plugins/plugin-security/src/security-plugin.ts#computeRlsFilter`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`). +2. **Pre-resolved membership (§7.3.1) already exists.** The RLS compiler recognizes `field IN (current_user.)` and resolves `` against `ExecutionContext.rlsMembership` — "the runtime resolves set-membership that would otherwise need a subquery … and stages each set here under a stable key" (`packages/spec/src/kernel/execution-context.zod.ts#rlsMembership`; merge at `packages/plugins/plugin-security/src/rls-compiler.ts`). **This is the seam controlled_by_parent plugs into — no new compiler form.** +3. **By-id write pre-image check (#1994).** `packages/plugins/plugin-security/src/security-plugin.ts#sharingModel` already re-reads the target row under the write-op RLS filter before an update/delete and denies if invisible. This is the exact hook to extend with a master-access check. +4. **`sharingModel` enforcement seam.** `packages/plugins/plugin-sharing/src/sharing-service.ts#buildReadFilter` reads `object.sharingModel`; `buildReadFilter` gates on it (`effectiveSharingModel(schema) !== 'private'`). A `controlled_by_parent` baseline plugs in here / in the security middleware. +5. **Master-detail storage.** A `master_detail` field's **key is the FK column**; its `reference` (`packages/spec/src/data/field.zod.ts#reference`) names the master object. Given a detail row, the master id is `row[masterFieldKey]`. 6. **Spec inconsistency (to fix either way).** `OWDModel` (`sharing.zod.ts`) includes `controlled_by_parent`; the object's authorable `sharingModel` (`object.zod.ts`) is a different enum `['private','read','read_write','full']` that omits it. ## Decision @@ -47,13 +47,13 @@ This composes with the detail's own tenant/owner RLS (all AND-ed) and flows to a | Option | Verdict | Why | |---|---|---| -| (a) query-time subquery join (`masterFK IN (SELECT id FROM master WHERE …)`) | ✗ rejected | the RLS compiler **deliberately has no subquery support** (`rls-compiler.ts:129-138`); the query AST has no EXISTS/sub-select form. Would require extending both. | +| (a) query-time subquery join (`masterFK IN (SELECT id FROM master WHERE …)`) | ✗ rejected | the RLS compiler **deliberately has no subquery support** (`packages/plugins/plugin-security/src/rls-compiler.ts`); the query AST has no EXISTS/sub-select form. Would require extending both. | | **(b) pre-resolved accessible-master-id set** | ✓ **chosen** | reuses the `rlsMembership` + IN-form path with **zero compiler changes**; resolution is one async pre-query per request, composes with existing RLS, reaches analytics. | | (c) materialized accessible-ids column | ✗ rejected | dual-write maintenance; goes stale the moment access rules/shares change mid-session — a correctness hazard for a security primitive. | ### 3. Write mechanism — extend the #1994 pre-image check -In the pre-image block (`security-plugin.ts:341-400`), for a `controlled_by_parent` detail `update`/`delete`/`create`: +In the pre-image block (`packages/plugins/plugin-security/src/security-plugin.ts#controlled_by_parent`), for a `controlled_by_parent` detail `update`/`delete`/`create`: - resolve the target's master id (`row[masterFieldKey]`; for `create`, the master id in the incoming body), and - re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny. - **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2). diff --git a/docs/adr/0056-permission-model-landing-verification.md b/docs/adr/0056-permission-model-landing-verification.md index a7f3083f1b..840f042f2c 100644 --- a/docs/adr/0056-permission-model-landing-verification.md +++ b/docs/adr/0056-permission-model-landing-verification.md @@ -50,25 +50,25 @@ Legend — **E** enforced · **P** partial · **U** declared-but-unenforced (fai | # | Primitive | Enforced? | Enforcement site (evidence) | Proven e2e? | Verdict | | :-- | :-- | :-: | :-- | :-: | :-- | -| 1 | Object CRUD (`allowRead/Create/Edit/Delete`) | **E** | `plugin-security/security-plugin.ts` ~`326` checkObjectPermission (fail-closed 403) | partial (delete ✗pf) | OK; add DELETE proof | -| 2 | FLS read-mask / write-deny | **E** | `security-plugin.ts` ~`441`/`530`, `field-masker.ts` | unit only ✗pf | OK; **prove FLS+RLS composition** | -| 3 | RLS `using` (read) | **E** | `security-plugin.ts` ~`504` AND-inject; `rls-compiler.ts` | ✓ `rls-fixture` | OK | +| 1 | Object CRUD (`allowRead/Create/Edit/Delete`) | **E** | `packages/plugins/plugin-security/src/security-plugin.ts` checkObjectPermission (fail-closed 403) | partial (delete ✗pf) | OK; add DELETE proof | +| 2 | FLS read-mask / write-deny | **E** | `packages/plugins/plugin-security/src/security-plugin.ts`, `field-masker.ts` | unit only ✗pf | OK; **prove FLS+RLS composition** | +| 3 | RLS `using` (read) | **E** | `packages/plugins/plugin-security/src/security-plugin.ts` AND-inject; `rls-compiler.ts` | ✓ `rls-fixture` | OK | | 4 | RLS `check` (insert/update) | **E** | `security-plugin.ts` write path | ✓ `rls-fixture` | OK | -| 5 | RLS by-id write (#1994) | **E** | `security-plugin.ts` ~`361` pre-image re-read | ✓ `rls-fixture` | OK | +| 5 | RLS by-id write (#1994) | **E** | `packages/plugins/plugin-security/src/security-plugin.ts` pre-image re-read | ✓ `rls-fixture` | OK | | 6 | RLS compiler grammar | **P** | `rls-compiler.ts` — only `=`(id/email/org)/`IN`/literal/`1=1`; **uncompilable predicates silently dropped** | unit | **D4: no silent drop** | | 7 | `current_user.*` vars | **P** | resolves `id`,`email`,`organization_id`,`org_user_ids`,`rlsMembership`; **`name` excluded by design** | unit | OK (documented) | -| 8 | OWD `private` (owner-only) | **P** | `plugin-sharing/sharing-service.ts` ~`53` `effectiveSharingModel` — **only if sharing plugin loaded** | ✓ `rls-fixture` | **D1/D3** | +| 8 | OWD `private` (owner-only) | **P** | `packages/plugins/plugin-sharing/src/sharing-service.ts#effectiveSharingModel` `effectiveSharingModel` — **only if sharing plugin loaded** | ✓ `rls-fixture` | **D1/D3** | | 9 | OWD `read` / `public_read` | **U** | `read` handled; `public_read` falls through to "public" (no filter) | ✗pf | **D1** | | 10 | OWD `read_write`/`full`/`public_read_write` | **U** | all collapse to default-ALLOW; no distinction | ✗pf | **D1 (fail-open)** | -| 11 | `controlled_by_parent` | **E** | `security-plugin.ts` ~`864`/`908` (read+write) | ✓ `controlled-by-parent`, `showcase-invoice-cbp` | OK (ADR-0055) | -| 12 | Ownership `owner_id` stamp + scope | **E** | `security-plugin.ts` ~`479` auto-stamp; owner RLS | ✓ `rls-fixture` | OK | -| 13 | Manual record shares (`sys_record_share`) | **E** | `sharing-service.ts` ~`117` buildReadFilter | unit ✗pf | OK; add proof | +| 11 | `controlled_by_parent` | **E** | `packages/plugins/plugin-security/src/security-plugin.ts` (read+write) | ✓ `controlled-by-parent`, `showcase-invoice-cbp` | OK (ADR-0055) | +| 12 | Ownership `owner_id` stamp + scope | **E** | `packages/plugins/plugin-security/src/security-plugin.ts` auto-stamp; owner RLS | ✓ `rls-fixture` | OK | +| 13 | Manual record shares (`sys_record_share`) | **E** | `sharing-service.ts` buildReadFilter | unit ✗pf | OK; add proof | | 14 | Sharing rules (criteria/owner) | **P** | rules materialize into `sys_record_share`; **spec CEL `condition`+recipients diverge from runtime `criteria_json`** | unit ✗pf | **D5 (spec↔runtime reconcile, per 0049)** | -| 15 | Role hierarchy widening (`parent`) | **N** | `sharing-rule-service.ts` ~`253` `expandRecipient` has no `role_and_subordinates` case (declared in the enum, never expanded); `team-graph.ts:27` flat; no consumer of `Role.parent` | ✗ | **D6: implement or `experimental`** | -| 16 | Multi-tenant org isolation | **E** | `plugin-org-scoping` ~`129` stamp + wildcard RLS + field-existence fail-closed | ✓ `rls-multitenant` | OK | +| 15 | Role hierarchy widening (`parent`) | **N** | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient` `expandRecipient` has no `role_and_subordinates` case (declared in the enum, never expanded); `packages/plugins/plugin-sharing/src/team-graph.ts` flat; no consumer of `Role.parent` | ✗ | **D6: implement or `experimental`** | +| 16 | Multi-tenant org isolation | **E** | `plugin-org-scoping` stamp + wildcard RLS + field-existence fail-closed | ✓ `rls-multitenant` | OK | | 17 | Anonymous / unauthenticated | **P/U** | `rest-server.ts` `requireAuth` **defaults false**; no context ⇒ checks skipped ⇒ reads unscoped data | unit ✗pf | **D2 (fail-open, HIGH)** | -| 18 | `systemPermissions` / tab-app gating | **E** | `rest-server.ts` ~`1069` filterAppForUser (server-side) | ✗pf | OK; add proof | -| 19 | Default/fallback provisioning | **P** | `security-plugin.ts` ~`724` hardcoded `member_default`; **not app-declarable** | unit ✗pf | **D7** | +| 18 | `systemPermissions` / tab-app gating | **E** | `packages/rest/src/rest-server.ts#filterAppForUser` filterAppForUser (server-side) | ✗pf | OK; add proof | +| 19 | Default/fallback provisioning | **P** | `packages/plugins/plugin-security/src/security-plugin.ts` hardcoded `member_default`; **not app-declarable** | unit ✗pf | **D7** | | 20 | `allowTransfer/Restore/Purge`, `Policy`, flow `runAs` | **(removed)** | — | — | already 0049 → M2, leave | | 21 | Compliance / Encryption / Masking / `RLSConfig` / DataClassification | **U** | declared in `spec/system/*`; no runtime consumer found | ✗ | **D8: triage per 0049** | @@ -116,7 +116,7 @@ The spec declares `CriteriaSharingRuleSchema`/`OwnerSharingRuleSchema` with a CE ### D6 — Role-hierarchy widening: implement, or mark `experimental` -`Role.parent` exists and is documented as "managers see subordinates," but no code consumes it: `expandRecipient` (`sharing-rule-service.ts:253`) resolves a `role` recipient to its **direct** members only and has **no `role_and_subordinates` branch** (the enum value ships, unexpanded). It is **silently a no-op** — a 0049 violation. (Department hierarchy *is* walked — `department-graph.ts:32` — so the gap is role-specific.) +`Role.parent` exists and is documented as "managers see subordinates," but no code consumes it: `expandRecipient` (`packages/plugins/plugin-sharing/src/sharing-rule-service.ts#expandRecipient`) resolves a `role` recipient to its **direct** members only and has **no `role_and_subordinates` branch** (the enum value ships, unexpanded). It is **silently a no-op** — a 0049 violation. (Department hierarchy *is* walked — `department-graph.ts` — so the gap is role-specific.) - **Decision: mark `Role.parent`'s visibility-rollup semantics `[EXPERIMENTAL — not enforced]`** in the spec now (it is not on the critical path for v1), with a roadmap entry to implement it as a `rlsMembership` pre-resolution (`current_user.subordinate_user_ids`) reusing the ADR-0055 IN-form — **no compiler change**. (Implementing now is acceptable if cheap; the default is honest-tagging over a silent no-op.) diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index 19066b579e..1e022cc0e8 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -69,14 +69,14 @@ object, which an AI author cannot reliably produce and ADR-0049 forbids shipping - **`sys_role` is ObjectStack-native**, not better-auth. better-auth's 18 managed tables do not include it; better-auth only owns the `role` *string* on `sys_member` - (`packages/platform-objects/src/identity/sys-member.object.ts:152`, `managedBy:'better-auth'`, enum + (`packages/platform-objects/src/identity/sys-member.object.ts#managedBy`, `managedBy:'better-auth'`, enum owner/admin/member) and `sys_user`. The word "role" collides; the *table* does not. The platform may own the concept outright. - **Scope-depth and rollup need no RLS-compiler change.** The compiler already compiles `field IN (current_user.)` against arbitrary pre-resolved sets supplied through - `ExecutionContext.rlsMembership` (`packages/plugins/plugin-security/src/rls-compiler.ts:126`, `:225`); + `ExecutionContext.rlsMembership` (`packages/plugins/plugin-security/src/rls-compiler.ts`, `packages/plugins/plugin-security/src/rls-compiler.ts`); `org_user_ids` is the existing precedent - (`packages/runtime/src/security/resolve-execution-context.ts:260`). New scopes resolve into new keys; + (`packages/runtime/src/security/resolve-execution-context.ts`). New scopes resolve into new keys; the engine's existing AND-injection (`packages/plugins/plugin-security/src/security-plugin.ts ~504`) applies them. @@ -100,7 +100,7 @@ as pre-resolved `IN` sets, never compiler subqueries. One decision per gap. ### D1 — Scope-depth on object grants (the ERP core) -Extend `ObjectPermissionSchema` (`packages/spec/src/security/permission.zod.ts:17`) so each per-object +Extend `ObjectPermissionSchema` (`packages/spec/src/security/permission.zod.ts#ObjectPermissionSchema`) so each per-object grant carries a **read scope** and a **write scope** drawn from a canonical enum: ``` @@ -151,7 +151,7 @@ subordinate_user_ids -> users below me on the sys_user.manager_id chain (manag ``` - Sourced by `BusinessUnitGraphService` (D2) and the existing `managerOf` chain - (`packages/plugins/plugin-sharing/src/team-graph.ts:94`). Bounded (hard cap + cache, mirroring the + (`packages/plugins/plugin-sharing/src/team-graph.ts#managerOf`). Bounded (hard cap + cache, mirroring the `org_user_ids` cap) and **org-scoped**. - Rollup is **additive only** (widens, never restricts) and respects tenant isolation. It powers both D1's `unit*` scopes and sharing-rule recipients (D6). Because it is pure pre-resolution, the choice @@ -165,13 +165,13 @@ role". - `resolve-execution-context` resolves `ctx.roles` from `sys_user_role` (union `sys_member.role` during a transition window), replacing the current sole dependence on the better-auth membership string - (`packages/runtime/src/security/resolve-execution-context.ts:226`). + (`packages/runtime/src/security/resolve-execution-context.ts`). - `sys_member.role` is **reframed to org-administration only** (owner/admin/member) and **relabelled** in ObjectStack's projection to `org_membership_level` (the underlying better-auth column/API param stays `role`; we relabel the platform schema projection in `packages/plugins/plugin-auth/src/auth-schema-config.ts`). - Continue feeding declared role names to better-auth `additionalOrgRoles` - (`packages/plugins/plugin-auth/src/auth-manager.ts:657`) **only** so invitations to those role names + (`packages/plugins/plugin-auth/src/auth-manager.ts`) **only** so invitations to those role names are accepted — never as the authority for RBAC. ### D5 — `sys_role` is a *job role* (capability bundle), not a second hierarchy @@ -182,7 +182,7 @@ permission sets** (via the existing `sys_role_permission_set`) plus an optional `sys_role`.** Therefore: - **Retire the broken `sys_role.parent` path.** `role-graph.ts`'s `childRoles` query targets a `parent` - column `sys_role` does not have (`packages/plugins/plugin-sharing/src/role-graph.ts:52`; the ADR-0056 + column `sys_role` does not have (`packages/plugins/plugin-sharing/src/role-graph.ts`; the ADR-0056 D6 "landed" claim was proven only against a mock engine). Rather than add the missing column, the `role_and_subordinates` sharing recipient is **re-homed onto the BU subtree** (D2/D3) and renamed `unit_and_subordinates`, with `role_and_subordinates` kept as a deprecated alias that resolves @@ -202,12 +202,12 @@ activate — closing the #2077 "decorative metadata" gap. UI-created rows. Home: `plugin-security` (sibling to `bootstrapPlatformAdmin`, `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`). - **Sharing rules -> `sys_sharing_rule`**: seed inside `SharingServicePlugin.start()`'s `kernel:ready` - **before** `listRules()`/`bindRuleHooks` (`packages/plugins/plugin-sharing/src/sharing-plugin.ts:168`) + **before** `listRules()`/`bindRuleHooks` (`packages/plugins/plugin-sharing/src/sharing-plugin.ts#bindRuleHooks`) so hooks bind to a populated table. - **#1887 / ADR-0056 D5 — pick the canonical sharing-rule shape.** The spec `SharingRuleSchema` (CEL `condition`, `ownedBy`, `sharedWith` enum incl. `group`/`guest`) diverges from the runtime `sys_sharing_rule` (`criteria_json` JSON filter, `recipient_type`/`recipient_id`) and is flagged - `EXPERIMENTAL — NOT ENFORCED` (`packages/spec/src/security/sharing.zod.ts:97`). Decision: the + `EXPERIMENTAL — NOT ENFORCED` (`packages/spec/src/security/sharing.zod.ts`). Decision: the **runtime shape is canonical**. The seeder translates the directly-mappable authoring fields (`object`->`object_name`, `accessLevel`->`access_level`, `sharedWith{type,value}`->`recipient_type`/`recipient_id` for user/role/unit-and-subordinates), and @@ -301,19 +301,19 @@ sharing recipient e2e** (a manager + their unit subordinates gain access via the - ADRs: 0010, 0049, 0054, 0055, 0056. Issues: #2077 (seed roles/rules), #1887 (SharingRuleSchema disconnected). -- Capability shape: `packages/spec/src/security/permission.zod.ts:17` (`allow*` + +- Capability shape: `packages/spec/src/security/permission.zod.ts#ObjectPermissionSchema` (`allow*` + `viewAllRecords`/`modifyAllRecords`). -- RLS IN-form + membership injection: `packages/plugins/plugin-security/src/rls-compiler.ts:126`, - `packages/runtime/src/security/resolve-execution-context.ts:226`. +- RLS IN-form + membership injection: `packages/plugins/plugin-security/src/rls-compiler.ts`, + `packages/runtime/src/security/resolve-execution-context.ts`. - BU tree: `packages/platform-objects/src/identity/sys-department.object.ts`, `packages/plugins/plugin-sharing/src/department-graph.ts`. - Role concept: `packages/plugins/plugin-security/src/objects/sys-role.object.ts` (no `parent`), - `packages/plugins/plugin-sharing/src/role-graph.ts:52` (broken walk). -- better-auth boundary: `packages/platform-objects/src/identity/sys-member.object.ts:152`, - `packages/plugins/plugin-auth/src/auth-manager.ts:657`, + `packages/plugins/plugin-sharing/src/role-graph.ts` (broken walk). +- better-auth boundary: `packages/platform-objects/src/identity/sys-member.object.ts#managedBy`, + `packages/plugins/plugin-auth/src/auth-manager.ts`, `packages/plugins/plugin-auth/src/auth-schema-config.ts`. - Seeding precedent: `packages/plugins/plugin-security/src/bootstrap-platform-admin.ts`; sharing boot: - `packages/plugins/plugin-sharing/src/sharing-plugin.ts:168`. + `packages/plugins/plugin-sharing/src/sharing-plugin.ts#bindRuleHooks`. --- @@ -371,7 +371,7 @@ open/paid resolver seam). It did **not** settle how the three org-shaped objects - `sys_team` (better-auth) — the **flat collaboration grouping**. Left unaddressed, all three land flat in the Setup `group_people_org` menu -(`packages/platform-objects/src/apps/setup-nav.contributions.ts:48-55`) and read as three +(`packages/platform-objects/src/apps/setup-nav.contributions.ts#group_people_org`) and read as three competing "organisation" concepts — the ambiguity is sharpest in a single-tenant install, where `sys_organization` is a single, un-creatable row. This addendum closes that gap. One decision per gap (ADR-0049 discipline); no model change, only surfacing + one denormalised @@ -426,14 +426,14 @@ migration; BU stays usable as owning-unit coordinate, explicit sharing recipient people-picker filter even when its nav is hidden). Only the **nav entries** tier: - **Business Units.** `nav_business_units` today carries `requiresObject: 'sys_business_unit'` - (`setup-nav.contributions.ts:50`), but `platform-objects` registers that object + (`packages/platform-objects/src/apps/setup-nav.contributions.ts`), but `platform-objects` registers that object unconditionally, so the gate is **inert (always true)**. Move the entry **out** of `SETUP_NAV_CONTRIBUTIONS` and contribute it from the capability that gives BU teeth — the hierarchy-security capability (`@objectstack/security-enterprise` when present), or an explicit app opt-in feature `business-units` — exactly the ADR-0029 K2 pattern already used for webhooks/approvals/sharing ("nav lives and dies with the capability"). - **Organizations / Invitations.** `nav_organizations` / `nav_invitations` - (`setup-nav.contributions.ts:52-53`) carry no gate at all today. Add a + (`packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_organizations`) carry no gate at all today. Add a `multiOrgEnabled != false` visibility gate (same predicate as the create action). - **Mechanism gap to close first (ADR-0049 — gated, never silently inert).** The `create_organization` *action* gates via a `visible` CEL string, but a `NavigationItem` @@ -443,7 +443,7 @@ people-picker filter even when its nav is hidden). Only the **nav entries** tier ### D11 — Resolve the `team` naming collision: drop `team` from `sys_business_unit.kind` -`sys_business_unit.kind` includes `team` (`sys-business-unit.object.ts:96`, +`sys_business_unit.kind` includes `team` (`packages/platform-objects/src/identity/sys-business-unit.object.ts#sys_business_unit`, `company | division | department | team | office | cost_center`), colliding head-on with the first-class `sys_team`: a `kind='team'` BU walks `BusinessUnitGraphService` (hierarchical) while `sys_team` walks `TeamGraphService` (flat) — two "teams", different semantics, no UI @@ -568,15 +568,15 @@ Both consumers that need it still terminate in `sys_member.role` only, and neith `sys_user_role`: - **plugin-approvals** — `expandApprovers` dispatches a `role` approver to - `expandRoleUsers` (`approval-service.ts:306`), which queries `find('sys_member', { filter: { role } })` - (`approval-service.ts:380-389`; the doc comment at `:286` even pins the semantics to + `expandRoleUsers` (`packages/plugins/plugin-approvals/src/approval-service.ts#sys_member`), which queries `find('sys_member', { filter: { role } })` + (`packages/plugins/plugin-approvals/src/approval-service.ts#sys_member`; the doc comment at even pins the semantics to "`sys_member.role = value`"). No `sys_role` / `sys_user_role` read. - **plugin-sharing** — a `role` recipient resolves via `teamGraph.expandRoleUsers` - (`role-graph.ts:100`, `team-graph.ts:144`), whose terminal query is - `find('sys_member', …)` (`team-graph.ts:70-80`; comment `:25` "`sys_member.role` for + (`role-graph.ts`, `packages/plugins/plugin-sharing/src/team-graph.ts#expandRoleUsers`), whose terminal query is + `find('sys_member', …)` (`packages/plugins/plugin-sharing/src/team-graph.ts#sys_member`; comment "`sys_member.role` for tenant role expansion"). Sharing is *partially* further along — its `RoleHierarchyGraph` walks `sys_role.parent` to find subordinate roles - (`role-graph.ts:57`) — but the final role→**user** hop is still `sys_member`. + (`role-graph.ts`) — but the final role→**user** hop is still `sys_member`. Note `plugin-approvals` is **not** in this ADR's Consumers list; `plugin-sharing` **is**. That both behave identically is the key signal below. diff --git a/docs/adr/0061-record-search-architecture.md b/docs/adr/0061-record-search-architecture.md index 5ec40ba56f..4030cf5f95 100644 --- a/docs/adr/0061-record-search-architecture.md +++ b/docs/adr/0061-record-search-architecture.md @@ -17,14 +17,14 @@ Decision: **enforce** record search as one **metadata-driven, server-resolved** ## Context ### The declared-but-unenforced failure, again -ObjectStack's defining risk is not a crash but a primitive that is *declarable* yet *inert* (ADR-0049, ADR-0060). Record search is a clean instance: the `docs/audits/2026-06-record-search-liveness.md` evidence shows `$search` (`spec/.../query.zod.ts:454`) parsed but executed by no driver (`driver-sql`/`memory` both `fullTextSearch: false`), so it returns rows unfiltered. AI authorship amplifies this — a model emits `searchableFields` that *looks* wired and nothing reads it. +ObjectStack's defining risk is not a crash but a primitive that is *declarable* yet *inert* (ADR-0049, ADR-0060). Record search is a clean instance: the `docs/audits/2026-06-record-search-liveness.md` evidence shows `$search` (`packages/spec/src/data/query.zod.ts`) parsed but executed by no driver (`driver-sql`/`memory` both `fullTextSearch: false`), so it returns rows unfiltered. AI authorship amplifies this — a model emits `searchableFields` that *looks* wired and nothing reads it. ### The requirement is platform-wide, not "lookup multi-field search" Search surfaces, all sending the same `$search` today: - **Lookup picker** (`fields/LookupField`, `RecordPickerDialog`) — type-ahead + dialog. - **List quick-search** (`plugin-list/ListView`) — already sends `$searchFields` (drift). - **Global / ⌘K** (`app-shell/CommandPalette` → `react/useRecordSearch`) — client fan-out + client ranking. -- **Public-form picker** (`rest-server.ts:3751`) — the only working, scoped path. +- **Public-form picker** (`packages/rest/src/rest-server.ts`) — the only working, scoped path. - **Structured filters** (FilterUI, grid/report filter bars) — `$filter`/`$contains`, *not* full-text; out of scope here. - **Knowledge / RAG / vector** — separate semantic subsystem; out of scope here. diff --git a/docs/adr/0065-sdui-styling-model.md b/docs/adr/0065-sdui-styling-model.md index 33c473b079..8ee6456bf4 100644 --- a/docs/adr/0065-sdui-styling-model.md +++ b/docs/adr/0065-sdui-styling-model.md @@ -1,6 +1,6 @@ # ADR-0065: SDUI styling model — scoped style-objects over arbitrary Tailwind classes -**Status**: Accepted (2026-06-22) — open-mechanism half implemented: `style`/`responsiveStyles` on the spec UI envelope (`page.zod.ts:97,106`), reference scoped-styles compiler + four-property test (objectui `core/src/styling/scoped-styles.ts(.test)`), token/Tailwind lint (`lint/validate-responsive-styles.ts`). Cloud tier-policy + VLM gate remain the cloud half. +**Status**: Accepted (2026-06-22) — open-mechanism half implemented: `style`/`responsiveStyles` on the spec UI envelope (`packages/spec/src/ui/page.zod.ts#responsiveStyles`), reference scoped-styles compiler + four-property test (objectui `core/src/styling/scoped-styles.ts(.test)`), token/Tailwind lint (`lint/validate-responsive-styles.ts`). Cloud tier-policy + VLM gate remain the cloud half. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0026](./0026-client-ui-plugin-distribution.md) (client UI distribution), [ADR-0016](./0016-studio-package-authoring-and-publish.md) (package authoring/publish), [ADR-0049](./0049-no-unenforced-security-properties.md) (spec must not promise what the runtime can't deliver) **Consumers**: `@objectstack/spec` (UI component envelope), the objectui renderers (`@object-ui/components`), cloud SDUI page authoring, the AI metadata-authoring agents. @@ -20,7 +20,7 @@ Three independent failure axes, any one of which is disqualifying, all bite at o 1. **Compilation.** Tailwind is JIT-compiled at *build* time, scanning *source*. The renderer (objectui Console) is a shipped, frozen artifact whose CSS scans - only objectui's own `src` (`objectui: apps/console/src/index.css:12-19`), + only objectui's own `src` (`objectui: `objectui:apps/console/src/index.css``), **never** the page metadata. There is **no safelist**. So a class in metadata produces CSS *only if it coincidentally also appears in objectui source*. (The Pricing page renders today purely because all 16 of its classes happen @@ -62,7 +62,7 @@ platform. ### What we have UI components carry a loose `className` passthrough. The objectui renderers apply -it directly — e.g. `objectui: packages/components/src/renderers/basic/elements.tsx:87` +it directly — e.g. `objectui: `objectui:packages/components/src/renderers/basic/elements.tsx`` (`cn(VARIANT_CLASS, ALIGN_CLASS, schema?.className)`). The spec does **not** even formally model a styling field (`packages/spec/src/ui/component.zod.ts` defines per-component `properties` + `children`, no `style`). So styling today is "write @@ -93,11 +93,11 @@ Builder.io solves exactly this shape (visual content authored separately, rendered into arbitrary host stacks) and **does not bet on Tailwind classes**: - Styles are CSS **objects**, per breakpoint — - `Builder.io SDK: packages/sdks/src/types/builder-block.ts:42` + `Builder.io SDK: packages/sdks/src/types/builder-block.ts:42` (`responsiveStyles?: { large?, medium?, small?, xsmall?: Partial }`). - Responsive is an **explicit breakpoint map in the data model** (desktop-first: `large` is base, smaller sizes override via `@media (max-width: …)` — - `Builder.io SDK: packages/sdks/src/constants/device-sizes.ts:34`), **not** + `Builder.io SDK: packages/sdks/src/constants/device-sizes.ts:34` ), **not** `md:` utility variants the author writes. - At render, each block's styles compile to **id-scoped CSS** — `Builder.io SDK: packages/sdks/src/helpers/css.ts` (`createCssClass` emits diff --git a/docs/adr/0070-package-first-authoring.md b/docs/adr/0070-package-first-authoring.md index d301a302e7..a471305992 100644 --- a/docs/adr/0070-package-first-authoring.md +++ b/docs/adr/0070-package-first-authoring.md @@ -40,7 +40,7 @@ Built across the three surfaces and verified end-to-end in a real environment vi |---|---|---|---|---| | **Kernel** `protocol.saveMetaItem` (`@objectstack/objectql`) | `?package=` passed by caller; for a `runtime-only` write whose target is a *loaded code package*, the binding is **coerced to `null`** (`isLoadedPackage` guard, #2252) | **Yes** — by design today (the coercion *creates* nulls) | guarded (won't stamp into a loaded code pkg) | **[existing]** | | **Studio UI** (`../objectui`) | the package **selector** (`StudioHomePage`/`DirectoryPage`/`ResourceListPage` read `?package=`); a "Local / Custom" scope (`sys_metadata`) surfaces the null bucket (#1946); `PackagesPage` *can* create a writable package, but the create flow doesn't **require** one | **Yes** — default scope is a code pkg or the null bucket | partially (object becomes read-only — fixed for editability, not for *where it lives*) | **[existing]** | -| **AI authoring** (`../cloud` `service-ai-studio`) | `resolvePackageId` (`metadata-tools.ts:268-328`): explicit `packageId` -> target item's pkg -> conversation active pkg -> single-app pkg -> **auto-create `com.workspace`**; read-only pkgs **rejected** (`:319`); `create_package` / `set_active_package` / `apply_blueprint` (`ensureAppPackage`) exist | **Soft** — auto-defaults into a catch-all instead of requiring a choice | **rejected** (`metadata-tools.ts:319`) | **[existing]** — most advanced of the three | +| **AI authoring** (`../cloud` `service-ai-studio`) | `resolvePackageId` (`metadata-tools.ts`): explicit `packageId` -> target item's pkg -> conversation active pkg -> single-app pkg -> **auto-create `com.workspace`**; read-only pkgs **rejected** (); `create_package` / `set_active_package` / `apply_blueprint` (`ensureAppPackage`) exist | **Soft** — auto-defaults into a catch-all instead of requiring a choice | **rejected** (`metadata-tools.ts`) | **[existing]** — most advanced of the three | The primitives already exist on every surface (a package concept; a way to create one; a read-only/writable distinction in cloud). **What is missing is a single, enforced contract** so the three surfaces agree, and so authored metadata always has a managed home. @@ -68,7 +68,7 @@ The kernel exposes a single predicate — *is this package writable?* — reused Every authoring surface follows the same contract; none silently defaults into a catch-all: - **Studio UI** [new]: the scope selector defaults to a *writable* package (the user's most-recent base) or, when none exists, the create entry points (`新建`) **prompt to create a base** (reuse the existing `PackagesPage` create dialog) before opening the designer. New items are bound to the active writable package. The "Local / Custom" (`sys_metadata`) scope is removed as a create destination (see D5). -- **AI authoring** [new] (`../cloud` `service-ai-studio`): `resolvePackageId` **drops the auto-`com.workspace` fallback** (`metadata-tools.ts:304`) and the `applyDraft` re-bind (`:568-573`); when no writable package is resolvable it returns an actionable error directing the agent to `create_package` or `set_active_package`. `apply_blueprint` requires a package context (its `ensureAppPackage` is invoked only after an explicit create/confirm). Skill prompts (`metadata-authoring-skill.ts`, `solution-design-skill.ts`) are updated to make "establish the base first" a hard step, not a hint. +- **AI authoring** [new] (`../cloud` `service-ai-studio`): `resolvePackageId` **drops the auto-`com.workspace` fallback** (`metadata-tools.ts`) and the `applyDraft` re-bind (); when no writable package is resolvable it returns an actionable error directing the agent to `create_package` or `set_active_package`. `apply_blueprint` requires a package context (its `ensureAppPackage` is invoked only after an explicit create/confirm). Skill prompts (`metadata-authoring-skill.ts`, `solution-design-skill.ts`) are updated to make "establish the base first" a hard step, not a hint. - **Kernel** [new]: D1 is the backstop — even if a surface regresses, the write is rejected, not orphaned. ### D4 — The package is the lifecycle unit (delete / export / snapshot / duplicate) [new] diff --git a/docs/adr/0072-reference-scope-and-resolvability.md b/docs/adr/0072-reference-scope-and-resolvability.md index 502e23adfe..db1654455f 100644 --- a/docs/adr/0072-reference-scope-and-resolvability.md +++ b/docs/adr/0072-reference-scope-and-resolvability.md @@ -36,7 +36,7 @@ These are the same class of bug: **referencing something that is not actually in ### Evidence — how the runtime actually seeds references -**Flow / edge / decision conditions** (`packages/services/service-automation/src/engine.ts:946-964`): the engine seeds `$record` + `record` = the **raw** trigger record, **flattens** its fields to top-level (so bare `status`/`budget` resolve), and adds `previous`, flow `variables`, prior-node outputs, `$runId`/`$flowName`. Crucially the record is injected verbatim: +**Flow / edge / decision conditions** (`packages/services/service-automation/src/engine.ts#variables`): the engine seeds `$record` + `record` = the **raw** trigger record, **flattens** its fields to top-level (so bare `status`/`budget` resolve), and adds `previous`, flow `variables`, prior-node outputs, `$runId`/`$flowName`. Crucially the record is injected verbatim: ```js variables.set('record', context.record); @@ -50,22 +50,22 @@ for (const [k, v] of Object.entries(context.record)) | Surface | Evaluator (file:line) | In-scope references | Token shape | | :-- | :-- | :-- | :-- | -| Flow / edge / decision | `service-automation/src/engine.ts:946-964` | `record`,`previous`, **flattened record fields**, flow `variables`, node outputs, `$runId`/`$flowName` | bare **and** `record.x` | -| Formula field (`Field.expression`) | `objectql/src/engine.ts:119` | `{ now, timezone, user, org, record }` | `record.x` only | -| Validation (`script`/`cross_field`/`when`) | `objectql/src/validation/rule-validator.ts:289` | `{ record: {...previous,...patch}, previous }` | `record.x` only | -| Field `visibleWhen`/`requiredWhen`/`readonlyWhen` | `objectql/src/validation/rule-validator.ts:178-190` | merged `record`, `previous`, (`parent` for master-detail — server-bound for `readonlyWhen` since #4889; the client grid binds it for all three) | `record.x` only | -| Hook lifecycle `condition` | `objectql/src/hook-wrappers.ts:84` | `{ record }` | `record.x` only | -| RLS `using`/`check` (compile→filter) | `plugin-security/src/rls-compiler.ts:259` | `current_user.*` (+ pre-resolved membership), record field names | field operands; pushdown subset only | -| Sharing-rule `condition` (compile→filter) | `plugin-sharing/src/bootstrap-declared-sharing-rules.ts:61` | record fields only | field operands; pushdown subset only | -| Action/view/app `visible` | framework UI layer; roots in `@objectstack/formula cel-engine.ts:52-62` | `record`,`os`,`user`,`ctx`,`features`,… | `record.x` / namespaced | +| Flow / edge / decision | `packages/services/service-automation/src/engine.ts#variables` | `record`,`previous`, **flattened record fields**, flow `variables`, node outputs, `$runId`/`$flowName` | bare **and** `record.x` | +| Formula field (`Field.expression`) | `packages/objectql/src/engine.ts#expression` | `{ now, timezone, user, org, record }` | `record.x` only | +| Validation (`script`/`cross_field`/`when`) | `packages/objectql/src/validation/rule-validator.ts#cross_field` | `{ record: {...previous,...patch}, previous }` | `record.x` only | +| Field `visibleWhen`/`requiredWhen`/`readonlyWhen` | `packages/objectql/src/validation/rule-validator.ts#readonlyWhen` | merged `record`, `previous`, (`parent` for master-detail — server-bound for `readonlyWhen` since #4889; the client grid binds it for all three) | `record.x` only | +| Hook lifecycle `condition` | `packages/objectql/src/hook-wrappers.ts#condition` | `{ record }` | `record.x` only | +| RLS `using`/`check` (compile→filter) | `packages/plugins/plugin-security/src/rls-compiler.ts` | `current_user.*` (+ pre-resolved membership), record field names | field operands; pushdown subset only | +| Sharing-rule `condition` (compile→filter) | `packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts` | record fields only | field operands; pushdown subset only | +| Action/view/app `visible` | framework UI layer; roots in `@objectstack/formula packages/formula/src/cel-engine.ts` | `record`,`os`,`user`,`ctx`,`features`,… | `record.x` / namespaced | -The canonical CEL scope roots the engine recognises are declared in `@objectstack/formula cel-engine.ts:52-62`: `record, previous, input, output, os, vars, variables, automation, context, args, item, env, user, step, result, trigger, event, payload, data, params, config, settings, ctx, features, parent`. +The canonical CEL scope roots the engine recognises are declared in `@objectstack/formula packages/formula/src/cel-engine.ts`: `record, previous, input, output, os, vars, variables, automation, context, args, item, env, user, step, result, trigger, event, payload, data, params, config, settings, ctx, features, parent`. Two consequences for the picker model: - **Token shape is surface-specific** (flow = bare-or-dotted; everything else = dotted). The picker must know the surface. - There is **one canonical root allowlist** the validator should reuse, instead of a hand-rolled one. -**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts:129-132`): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). +**`outputSchema` today** (`packages/spec/src/automation/flow.zod.ts#outputSchema`): `Record`, **optional**, carries **no `objectName`**, is **not consumed by the engine**, and is read by the designer only for connector/Tool display. So it cannot, today, tell the picker *which object* an `object`-typed output holds. The reliable signal is the **producing node's `config.objectName`** (required on CRUD nodes) found by walking the graph back — exactly the walk the objectui picker already does (`@object-ui/app-shell` · `inspectors/flow-scope.ts` `flowAncestors`). ### What already shipped (Slice 0/1, objectui #1934) @@ -83,7 +83,7 @@ The flow data-picker and its inline validator already landed in objectui: graph- **D4 — Relationships are a guided fetch, never a lookup dot-walk.** Because the engine does not expand lookups (Evidence), `record..` is **forbidden** from the picker. When an author wants a related record's fields, the picker offers the lookup as a leaf (`record.account`, an id) plus an affordance — *"Account is a related record — add a Get Records step to load it"* — that scaffolds a `get_record` node whose output is then drillable (`account_data.*`). This is the Salesforce-Flow pattern and the only runtime-honest relational story. -**D5 — Validation pairs with the picker and reuses the engine's truth.** Inline validation flags (a) ADR-0032 brace/shape errors (deterministic, scope-free) and (b) scope-aware unknown roots. Its allowlist of "known roots" MUST track the engine's canonical scope roots (`@objectstack/formula cel-engine.ts:52-62`) rather than a private list, and it MUST skip surfaces/positions where it cannot decide without a fetch (e.g. the start node's bare trigger fields) to keep false positives at zero. Flow-level issues also surface as Problems-panel rows + canvas badges (ADR-0058 fail-policy: authoring-time guidance, non-blocking). +**D5 — Validation pairs with the picker and reuses the engine's truth.** Inline validation flags (a) ADR-0032 brace/shape errors (deterministic, scope-free) and (b) scope-aware unknown roots. Its allowlist of "known roots" MUST track the engine's canonical scope roots (`@objectstack/formula packages/formula/src/cel-engine.ts`) rather than a private list, and it MUST skip surfaces/positions where it cannot decide without a fetch (e.g. the start node's bare trigger fields) to keep false positives at zero. Flow-level issues also surface as Problems-panel rows + canvas badges (ADR-0058 fail-policy: authoring-time guidance, non-blocking). **D6 — `outputSchema` evolves into the authoritative output-typing channel.** Add an optional `objectName` (and array-item type) to `FlowNode.outputSchema` entries so an `object`-typed output can name its object — making D3 work for *any* producer, including connectors and subflows that have no `config.objectName`. Built-in CRUD executors populate it from their `objectName`; the designer prefers `outputSchema.objectName`, falling back to the graph-walk. Until then, D3 runs on `config.objectName`. (Spec change owned by this repo; coordinate the designer change in objectui.) @@ -116,7 +116,7 @@ The flow data-picker and its inline validator already landed in objectui: graph- 1. **Auto-expand lookups (`record.account.name`).** Rejected — the engine does not expand lookups (Evidence); every such token would resolve to `undefined`, violating D1. This is the literal #1934 follow-up ask; the investigation that killed it is the reason for this ADR. 2. **Pre-fetch the whole schema graph and show a flat list.** Rejected — N+M fetches on open, hundreds of entries, and still can't represent relationships honestly. The lazy tree (D3) scales and stays quiet. -3. **A private "known globals" allowlist in the validator.** Rejected for the long term — drifts from the engine. D5 reuses `cel-engine.ts:52-62`. +3. **A private "known globals" allowlist in the validator.** Rejected for the long term — drifts from the engine. D5 reuses `packages/formula/src/cel-engine.ts`. 4. **Designer-only `outputSchema` typing (no spec change).** Rejected as the end state — connectors/subflows have no `config.objectName`; D6 makes typing authoritative and plugin-extensible. ## Open questions diff --git a/docs/adr/0079-record-display-name.md b/docs/adr/0079-record-display-name.md index c3edf88f36..aeb222f08b 100644 --- a/docs/adr/0079-record-display-name.md +++ b/docs/adr/0079-record-display-name.md @@ -73,7 +73,7 @@ opens. The specific defects the implementation names: - **`titleFormat` is unqueryable by construction.** It is a render-only - template. `packages/lint/src/validate-record-title.ts:92` states the + template. `packages/lint/src/validate-record-title.ts` states the consequence: *"titleFormat is a render-only template the server cannot return or query"*. A title the server cannot return cannot be sorted on, searched, or sent in a notification body — so a title expressed only as a template is @@ -98,7 +98,7 @@ Not a template, not a client-side composition. > "A record's human title is a STRUCTURAL INVARIANT: every object has exactly > one primary title field, which is a real STORED field (text / autonumber / > formula whose result is text)." -> — `packages/spec/src/data/display-name.ts:6-8` +> — `packages/spec/src/data/display-name.ts` ### D2 — `nameField` is canonical; `displayNameField` is a deprecated alias @@ -108,20 +108,20 @@ contract: - `ObjectSchema.parse` / `.safeParse` / `.create()` copy `displayNameField` onto `nameField` when `nameField` is absent - (`normalizeNameFieldAlias`, `packages/spec/src/data/object.zod.ts:2082-2095`, - installed on the parse path at `:2149-2158` and reached by `create()` at - `:2204`). + (`normalizeNameFieldAlias`, `packages/spec/src/data/object.zod.ts#normalizeNameFieldAlias`, + installed on the parse path at `packages/spec/src/data/object.zod.ts` and reached by `create()` at + `packages/spec/src/data/object.zod.ts`). - **Both keys are preserved on the parsed output** — deliberately, for cross-repo consumers and older tests that still read the old spelling - (`object.zod.ts:2084-2085`). + (`packages/spec/src/data/object.zod.ts#displayNameField`). - The schema's own text marks the direction: `displayNameField` describes - itself as `[DEPRECATED → nameField]` (`object.zod.ts:1698`). + itself as `[DEPRECATED → nameField]` (`packages/spec/src/data/object.zod.ts#nameField`). This is the alias mechanic ADR-0085 later adopted verbatim: *"Mechanics follow ADR-0079's `displayNameField → nameField` precedent exactly: `compactLayout` is accepted as a parse-time alias, copied onto `highlightFields`, both preserved on output, describe marks the old key deprecated."* -(`docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md:57`) +(`docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md`) ### D3 — `titleFormat` is retired in favour of `nameField` @@ -139,8 +139,8 @@ the diagnostic is advisory, not an error: **Migration is stated, not left to the author**: a single-field title becomes `nameField: ''`; a **composite** title becomes a `formula` field with `returnType: 'text'`, designated as the `nameField` -(`validate-record-title.ts:91-96`; the worked example is -`content/docs/data-modeling/formulas.mdx:94-102`). +(`packages/lint/src/validate-record-title.ts`; the worked example is +`content/docs/data-modeling/formulas.mdx`). ### D4 — One resolution order, shared by every consumer @@ -161,10 +161,10 @@ Derivation, restricted to title-eligible fields, is ranked: The two runtime seams that consume it compute it identically, and each says so where it does: -- `packages/objectql/src/engine.ts:5212` — *"[ADR-0079] `nameField` is the +- `packages/objectql/src/engine.ts` — *"[ADR-0079] `nameField` is the canonical primary-title pointer; `displayNameField` is the deprecated alias (still honored)"*, feeding `expandSearchToFilter`. -- `packages/metadata-protocol/src/protocol.ts:4793` — *"[ADR-0079] Same +- `packages/metadata-protocol/src/protocol.ts` — *"[ADR-0079] Same precedence the engine's search expansion applies"*, feeding the REST `$searchFields` ingress gate. @@ -184,16 +184,16 @@ Two judgement calls are recorded rather than left implicit: - **`email` is eligible, `phone` is not.** *"`phone` is deliberately excluded (a phone number is not a title); `email` IS eligible (commonly the human handle - on identity-ish objects)"* (`display-name.ts:71-72`). + on identity-ish objects)"* (`packages/spec/src/data/display-name.ts`). - **`autonumber` is a valid primary but is never *derived*.** *"an autonumber is a valid primary only when an author points at it explicitly … not something we - silently pick"* (`display-name.ts:66-68`). + silently pick"* (`packages/spec/src/data/display-name.ts`). ### D6 — A record never renders as "Untitled"; the floor is `Record #` `resolveRecordDisplayName` returns the value at the resolved field, and falls back to a stable `Record #` — *"NEVER a bare 'Untitled'"* -(`display-name.ts:13-14`). A view may override the object's choice for one +(`packages/spec/src/data/display-name.ts#viewTitleField`). A view may override the object's choice for one render via `viewTitleField` (e.g. a list view labelling rows by another column); that override is per-render and does not change the object's title. @@ -201,7 +201,7 @@ that override is per-render and does not change the object's title. `SchemaRegistry.registerObject` runs `provisionPrimary(schema, { synthesize: false })` — for **owned** objects only, after `applySystemFields` -(`packages/objectql/src/registry.ts:1079-1090`). +(`packages/objectql/src/registry.ts`). - Where a title-eligible field already exists, `nameField` is **designated** — so it is reliably populated for normal / user-built / AI-built objects. @@ -223,18 +223,18 @@ returns `status: 'none'`) — and **never errors**: > "Both are warnings: the auto-provision transform and the id floor mean a green > build never ships a fully title-less object." -> — `packages/lint/src/validate-record-title.ts:26-27` +> — `packages/lint/src/validate-record-title.ts` It runs on `os build` / `os validate` / `os lint`, the MCP authoring surface and hand authoring — *not* only on the cloud graph-lint path. That is ADR-0078's "not cloud-only" principle applied -(`validate-record-title.ts:12-14`, `packages/lint/src/authoring-rules.ts:668-678`). +(`packages/lint/src/validate-record-title.ts`, `packages/lint/src/authoring-rules.ts`). ### D9 — There is exactly one title pointer; a second is not a tolerable alias `primaryField` was read as a title pointer by two lint rules and was **removed**, not declared, in #6326. The reasoning is recorded at -`packages/lint/src/data-model-rules.ts:404-409`: +`packages/lint/src/data-model-rules.ts`: > "The maintainer ruled remove, not declare: `nameField` is ADR-0079's one > canonical title pointer and a second parallel pointer contradicts 'one Zod @@ -253,12 +253,12 @@ contract. Same-looking code, opposite verdicts. Search-adjacent consumers read the resolved display field, but it does not buy the field an exemption from their own exclusion rules. `$search` field resolution leads with the display field **as ordering, never as membership** -(`packages/spec/src/data/search-fields.ts:75-92`, #4483) — the concrete failure +(`packages/spec/src/data/search-fields.ts`, #4483) — the concrete failure that forced the distinction being D7's designate-only pass setting `nameField: 'id'` on tables whose only textual column is the primary key, which had turned `$search` into a substring scan over the primary key. The ADR-0098 pinyin companion column likewise takes *only* the resolved display field as its source -(`packages/objectql/src/search-companion.ts:19`, `:104`). +(`packages/objectql/src/search-companion.ts`, `packages/objectql/src/search-companion.ts`). --- @@ -283,7 +283,7 @@ them needs a maintainer, and — for the first three — probably the cloud orig 1. **When, if ever, does `nameField` become required?** #2434 recorded "No hard `.refine()` requiring a title (would reject existing metadata)" and - `object.zod.ts:1684-1685` still says *"Optional at the schema level for now + `packages/spec/src/data/object.zod.ts` still says *"Optional at the schema level for now (a hard required-refine is staged)"*. Two months on, "staged" names no trigger and no criterion. Nothing in the tree says what would make it fire. @@ -303,7 +303,7 @@ them needs a maintainer, and — for the first three — probably the cloud orig word. 4. **`code` is name-like to lint and not to spec.** `packages/lint/src/data- - model-rules.ts:36`'s `NAME_LIKE_FIELDS` includes `code`; spec's derivation + model-rules.ts`'s `NAME_LIKE_FIELDS` includes `code`; spec's derivation set (`display-name.ts:NAME_ISH_EXACT`) does not. So an object whose only name-ish field is `code` passes lint's R9 "has a title face" check while `resolveDisplayField` will not derive `code` as its title (it may still be diff --git a/docs/adr/0080-ai-authored-ui-jsx-source.md b/docs/adr/0080-ai-authored-ui-jsx-source.md index 96247e2f10..f6cff748b0 100644 --- a/docs/adr/0080-ai-authored-ui-jsx-source.md +++ b/docs/adr/0080-ai-authored-ui-jsx-source.md @@ -7,7 +7,7 @@ **Premise**: the current SDUI page metadata is a fixed-shape, slotted schema. To let AI customize pages with real layout freedom we need a richer authoring surface — but the three obvious richer surfaces each fail a hard constraint, and the failure modes are exactly the ones ADR-0077/0078 warn about (ambiguous surface + silent wrongness). This ADR pins the surface, the contract, and where compilation runs, **before** the AI authors a large body of pages against an ambiguous one. It adds almost no runtime to the renderer: it adds a *source format* and a *compiler in front of the existing pipeline*. -> **Trigger**: a design review of "the page metadata can't express AI-flexible layouts; HTML can't reach our component library; React needs a build/deploy and is unsafe to execute." The investigation found that the renderer is **already** a recursive tree interpreter over a registered component set (`@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get(type)` → `React.createElement`, children recurse), and that the registry **already declares component props** at registration (`ComponentInput` in `packages/core/src/registry/Registry.ts:13`). So the missing piece is not the runtime — it is the *authoring format* (AI is fluent in JSX/Tailwind, not in hand-written JSON trees) and the *contract projection* that makes AI output checkable. The wrong fix — store the JSON tree as the thing AI edits, or lift every component's props into the framework Zod spec — was considered and rejected (see Decision §3, §5). +> **Trigger**: a design review of "the page metadata can't express AI-flexible layouts; HTML can't reach our component library; React needs a build/deploy and is unsafe to execute." The investigation found that the renderer is **already** a recursive tree interpreter over a registered component set (`@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get(type)` → `React.createElement`, children recurse), and that the registry **already declares component props** at registration (`ComponentInput` in `objectui:packages/core/src/registry/Registry.ts`). So the missing piece is not the runtime — it is the *authoring format* (AI is fluent in JSX/Tailwind, not in hand-written JSON trees) and the *contract projection* that makes AI output checkable. The wrong fix — store the JSON tree as the thing AI edits, or lift every component's props into the framework Zod spec — was considered and rejected (see Decision §3, §5). --- @@ -17,7 +17,7 @@ 1. **[model] AI authors a constrained *JSX text*, not a JSON tree.** The JSON `SchemaNode` tree is a fine *compile target* and a terrible *edit surface* — verbose, out-of-distribution for the model, noisy to diff, and it loses manual tweaks on every regeneration. AI reads/edits JSX+Tailwind (its strength); the tree is **derived**, never hand-edited. (This is how v0/Markdoc/MDX keep source = code, output = compiled.) 2. **[ruled] parse ≠ execute (the Markdoc model).** The JSX text is **parsed** to an AST and **interpreted** by mapping tags to registered components — no JS is ever run. This is categorically different from real React (`import`/bundle/eval): it keeps multi-tenant-as-data, needs no build/deploy, and never executes untrusted code. A stored string that is *parsed* is still data; a stored module that is *executed* is not. Looks like React, is not React. -3. **[ruled] The component registry is the authoritative contract — do not duplicate it into the framework Zod spec.** `ComponentInput` already carries `name / type / required / enum / description / defaultValue / isContainer / slot`. That is enough to (a) codegen the JSX type surface (`.d.ts` → `JSX.IntrinsicElements`) and (b) validate AI output at the level that catches the real errors (unknown component, unknown/missing prop, wrong coarse type, illegal enum value). The framework spec owns only the **tree envelope** (page/region/children recursion) and **consumes** a serialized manifest of the registry — it must **not** re-declare per-component props. (`page.zod.ts:85` is today `properties: z.record(z.unknown())` — ungated, the same `z.record` escape as the flow-node-config debt; the fix is the registry manifest, not a parallel hand-authored Zod copy.) +3. **[ruled] The component registry is the authoritative contract — do not duplicate it into the framework Zod spec.** `ComponentInput` already carries `name / type / required / enum / description / defaultValue / isContainer / slot`. That is enough to (a) codegen the JSX type surface (`.d.ts` → `JSX.IntrinsicElements`) and (b) validate AI output at the level that catches the real errors (unknown component, unknown/missing prop, wrong coarse type, illegal enum value). The framework spec owns only the **tree envelope** (page/region/children recursion) and **consumes** a serialized manifest of the registry — it must **not** re-declare per-component props. (`packages/spec/src/ui/page.zod.ts#defaultValue` is today `properties: z.record(z.unknown())` — ungated, the same `z.record` escape as the flow-node-config debt; the fix is the registry manifest, not a parallel hand-authored Zod copy.) 4. **[model] capability ≠ contract.** All ~244 registered types remain a rendering *capability*. Only a **curated public tier (~35 blocks)** — shaped like Salesforce's App Builder standard components, object-centric, generated from a `tier:'public'` flag — is what gets type-checking, the api-surface ratchet, customer docs, and the AI vocabulary. Do not freeze 244 internal/admin/studio components into a versioned contract. 5. **[ruled] Authoritative parse + sanitize + validate + compile runs *server-side at save*; the client renders the *compiled tree*, not the JSX.** Sanitization cannot be client-trusted; binding-correctness needs server-only object schemas; the completeness gate (ADR-0078) and draft-gating (ADR-0033) are already server-side. Store `{ source, compiledTree, requires }` — **source is truth, tree is derived cache.** The client ships **no parser on the render path** (web/mobile render the same pre-validated tree); a client-side copy of the shared parser exists **only** for the live edit/preview loop and is re-validated server-side at save — it is never the trust boundary. 6. **[staging] Two coupled deliverables, neither blocking the other.** (a) *Complete the registry `inputs`* for the ~35 public blocks (closes existing designer-contract gaps too); (b) *add a JSX-source page kind* (additive — the existing tree page stays; designer round-trip to JSX is deferred). They meet through a codegen'd `.d.ts` (author-time type-check) and a serialized manifest (save-time validate). v1 ships with shallow inputs-validation; depth (nested shapes, binding-against-object-schema) is added incrementally on the public tier only. @@ -33,8 +33,8 @@ The SDUI runtime is **not** the thing that needs to change: | Capability | Where it already lives | |---|---| | Recursive tree interpreter (`type` → component, children recurse) | `@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get()` → `React.createElement` | -| Recursive node schema (`{ type, props, children }`, `className`/`style`, `visibleOn`) | `@object-ui/types` `BaseSchema` / discriminated `LayoutSchema` (`packages/types/src/layout.ts:639`, literal `type` discriminants) | -| Per-component prop declaration | `ComponentInput[]` on `register()` (`packages/core/src/registry/Registry.ts:13`) | +| Recursive node schema (`{ type, props, children }`, `className`/`style`, `visibleOn`) | `@object-ui/types` `BaseSchema` / discriminated `LayoutSchema` (`objectui:packages/types/src/layout.ts`, literal `type` discriminants) | +| Per-component prop declaration | `ComponentInput[]` on `register()` (`objectui:packages/core/src/registry/Registry.ts`) | | Conflict-free styling channels | typed primitive props → safelisted class map (`ResponsiveGrid.tsx`), and **scoped per-node CSS** (ADR-0065, `packages/core/src/styling/scoped-styles.ts`) | | Enumerable catalog across ~40 plugins | `ComponentRegistry.getAllConfigs()` / `getAllTypes()` | | An HTML escape hatch (today unsafe) | `renderers/basic/html.tsx` — raw `dangerouslySetInnerHTML`, no sanitize | @@ -84,7 +84,7 @@ The parser whitelists tags to the registry type set; strips `