diff --git a/.changeset/liveness-prove-it-runs.md b/.changeset/liveness-prove-it-runs.md new file mode 100644 index 0000000000..83a93104b8 --- /dev/null +++ b/.changeset/liveness-prove-it-runs.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +Add the ADR-0054 "prove-it-runs" proof field + ratchet to the spec liveness gate. A `live` ledger entry may now carry a `proof` — a reference (`#`) to a dogfood test that asserts the property's runtime behavior. A bound high-risk `live` property must carry a valid proof, validated statically by the liveness gate (the file exists and declares the matching `@proof:` tag). Four high-risk classes are bound this phase: field types (`field.type`), RLS (`permission.rowLevelSecurity.using`), flow nodes (`flow.nodes.type`), and analytics (`dataset.dimensions.dateGranularity`). The `dataset` metadata type is now governed (new `liveness/dataset.json`). The authoritative high-risk-class list lives in `scripts/liveness/proof-registry.mts`; see `liveness/README.md`. diff --git a/.changeset/verify-automation-flow-proofs.md b/.changeset/verify-automation-flow-proofs.md new file mode 100644 index 0000000000..2bb6cb50e8 --- /dev/null +++ b/.changeset/verify-automation-flow-proofs.md @@ -0,0 +1,5 @@ +--- +"@objectstack/verify": minor +--- + +`bootStack` gains an opt-in `automation` boot option. When set, it registers `@objectstack/service-automation` so the app's authored flows are pulled from the registry and `POST /api/v1/automation/:name/trigger` actually executes their nodes against the real in-process stack. This makes flow-node execution + variable wiring verifiable end-to-end (ADR-0054 Phase 2), mirroring the existing `multiTenant` opt-in. Default is `false`, so the standard boot stays lean for apps that don't exercise flows. diff --git a/.github/workflows/spec-liveness-check.yml b/.github/workflows/spec-liveness-check.yml index 2d4c233841..6118290db8 100644 --- a/.github/workflows/spec-liveness-check.yml +++ b/.github/workflows/spec-liveness-check.yml @@ -6,12 +6,18 @@ name: Spec Liveness Check # property of a GOVERNED type to declare a liveness status with evidence in # packages/spec/liveness/.json. A new unclassified property fails the check (the # ratchet: no new undeclared surface). See packages/spec/liveness/README.md. +# +# ADR-0054 (prove-it-runs): bound high-risk 'live' properties must carry a `proof` +# pointing to a dogfood test that declares the matching `@proof:` tag. The gate also +# triggers on packages/dogfood/** so deleting/renaming a proof re-runs this check and +# the dangling reference is caught (the proof files live outside packages/spec/). on: pull_request: types: [opened, synchronize, reopened] paths: - 'packages/spec/**' + - 'packages/dogfood/**' permissions: contents: read diff --git a/packages/dogfood/test/analytics-timezone.dogfood.test.ts b/packages/dogfood/test/analytics-timezone.dogfood.test.ts index 7d3492be1e..76a19aef9f 100644 --- a/packages/dogfood/test/analytics-timezone.dogfood.test.ts +++ b/packages/dogfood/test/analytics-timezone.dogfood.test.ts @@ -3,6 +3,12 @@ // GOLDEN REGRESSION for #1982 / #2018 — "organization timezone drives analytics // date bucketing", exercised end-to-end through the real HTTP + service stack. // +// @proof: analytics-tz-bucketing +// ADR-0054 runtime proof for the analytics high-risk class. Registered in +// proof-registry.mts but NOT yet ledger-bound: the authorable surface +// (dataset/report dimensions+measures) is not a GOVERNED liveness type yet, so +// there is no entry to carry the proof. Binds once dataset/report are governed. +// // This is the test that would have caught #2018 before merge. That bug passed // every static gate: it lived in the *integration* of NativeSQLStrategy routing // + in-memory count + REST execution-context resolution. Only booting the app diff --git a/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts index 6b7640d50c..9059c6d85a 100644 --- a/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts +++ b/packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts @@ -3,6 +3,11 @@ // CAPABILITY-MATRIX golden test — every authorable field type must survive a // real HTTP write → read round-trip. // +// @proof: field-type-roundtrip +// ADR-0054 runtime proof for the field-type high-risk class. Referenced by the +// liveness ledger entry `field.type` (packages/spec/liveness/field.json); the +// spec liveness gate fails if this tag is removed. See proof-registry.mts. +// // `showcase_field_zoo` carries one field of (almost) every protocol FieldType. // Until now it was only *static*-checked (the metadata bundle registers it); // nothing wrote a record and read it back. But the platform's value is that an diff --git a/packages/dogfood/test/fixtures/flow-touch-fixture.ts b/packages/dogfood/test/fixtures/flow-touch-fixture.ts new file mode 100644 index 0000000000..2b68c7a2bd --- /dev/null +++ b/packages/dogfood/test/fixtures/flow-touch-fixture.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Flow-node execution fixture — the deterministic ADR-0054 Phase-2 flow proof. +// +// A flow is `live` in the ledger because the automation engine *reads* its +// nodes — but "reads" is not "runs correctly end-to-end". A node's value +// crosses flow-trigger → variable context → CEL/template interpolation → the +// data engine, and the break can live in any seam (e.g. an input variable that +// never reaches a node's config, or an `update_record` that ignores its +// filter). This fixture proves the integrated path with ZERO dependence on an +// example app: one object `flow_note` and one `autolaunched` flow whose single +// `update_record` node stamps `status: 'processed'` on the record whose id was +// passed in as the `noteId` input variable. +// +// The proof asserts BOTH directions, mirroring the RLS fixture's rigor: +// • the targeted record IS mutated → the node executed, +// • a bystander record is NOT → the input variable actually flowed into +// the node's filter (not a blanket update). A flow that didn't wire the +// variable would either touch nothing or touch everything; only correct +// execution + wiring flips exactly the target. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** The one object under test: a note the flow stamps as processed. */ +export const FlowNote = ObjectSchema.create({ + name: 'flow_note', + label: 'Flow Note', + pluralLabel: 'Flow Notes', + fields: { + name: Field.text({ label: 'Name', required: true }), + status: Field.text({ label: 'Status' }), + }, +}); + +/** + * `flow_touch` — start → update_record → end. The `noteId` input variable is + * interpolated into the update filter (`{noteId}` template), and the node sets + * `status` to `processed`. Triggered via `POST /automation/flow_touch/trigger` + * with `{ params: { noteId } }`. + */ +export const flowTouch = { + name: 'flow_touch', + label: 'Flow Touch', + type: 'autolaunched', + variables: [{ name: 'noteId', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'mark_processed', + type: 'update_record', + label: 'Mark processed', + config: { + objectName: 'flow_note', + filter: { id: '{noteId}' }, + fields: { status: 'processed' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'mark_processed' }, + { id: 'e2', source: 'mark_processed', target: 'end' }, + ], +}; + +/** A minimal, self-contained app config the dogfood harness can boot. */ +export const flowFixtureStack = defineStack({ + manifest: { + id: 'com.dogfood.flow_fixture', + namespace: 'flow', + version: '0.0.0', + type: 'app', + name: 'Flow Node Fixture', + description: 'Single-object app whose flow exercises node execution + variable wiring (ADR-0054 Phase 2).', + }, + objects: [FlowNote], + flows: [flowTouch], +}); diff --git a/packages/dogfood/test/flow-node.dogfood.test.ts b/packages/dogfood/test/flow-node.dogfood.test.ts new file mode 100644 index 0000000000..b403102c08 --- /dev/null +++ b/packages/dogfood/test/flow-node.dogfood.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// FLOW NODE execution proof (ADR-0054 Phase 2), exercised end-to-end through the +// real HTTP + automation stack. +// +// @proof: flow-node-execution +// ADR-0054 runtime proof for the flow-node high-risk class (node execution + +// variable wiring). Referenced by the liveness ledger entry `flow.nodes.type` +// (packages/spec/liveness/flow.json); the spec liveness gate fails if this tag +// is removed. See proof-registry.mts. +// +// A flow being `live` means the engine reads its nodes — necessary but not +// sufficient. This authors a flow, triggers it over HTTP, and asserts the +// observable runtime outcome: the `update_record` node ran AND the `noteId` +// input variable wired into its filter, so EXACTLY the targeted record changed. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { flowFixtureStack } from './fixtures/flow-touch-fixture.js'; + +describe('objectstack verify FLOW: node execution + variable wiring (#flow-node)', () => { + let stack: VerifyStack; + let token: string; + + beforeAll(async () => { + // `automation: true` registers @objectstack/service-automation so the app's + // flows are pulled from the registry and the trigger route runs them. + stack = await bootStack(flowFixtureStack, { automation: true }); + token = await stack.signIn(); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + async function createNote(name: string): Promise { + const res = await stack.apiAs(token, 'POST', '/data/flow_note', { name, status: 'new' }); + expect(res.status, `create ${name} failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const j = (await res.json()) as { id?: string; record?: { id?: string } }; + const id = j.id ?? j.record?.id; + expect(id, 'no id returned from create').toBeTruthy(); + return id as string; + } + + async function statusOf(id: string): Promise { + const res = await stack.apiAs(token, 'GET', `/data/flow_note/${id}`); + expect(res.status).toBe(200); + const j = (await res.json()) as { record?: Record } & Record; + return (j.record ?? j).status; + } + + it('precondition: the automation service is wired and the flow is registered', async () => { + // The flow-list route is served by the same dispatcher; if automation were + // unregistered this would not return the flow (the whole proof would be moot). + const res = await stack.apiAs(token, 'GET', '/automation/flow_touch'); + expect(res.status, `automation service not wired: ${res.status}`).toBe(200); + }); + + it('runs the update_record node and wires the input variable into the filter', async () => { + const target = await createNote('target'); + const bystander = await createNote('bystander'); + expect(await statusOf(target)).toBe('new'); + expect(await statusOf(bystander)).toBe('new'); + + const res = await stack.apiAs(token, 'POST', '/automation/flow_touch/trigger', { + params: { noteId: target }, + }); + expect(res.status, `trigger failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const body = (await res.json()) as { success?: boolean; data?: { success?: boolean; error?: string } }; + expect(body.success).toBe(true); + expect(body.data?.success, `flow run not successful: ${JSON.stringify(body.data)}`).toBe(true); + + // The node executed → the targeted record was stamped. + expect(await statusOf(target)).toBe('processed'); + // Variable wiring is REAL → the filter used noteId, so the bystander is + // untouched. (A flow that dropped the variable would touch nothing or, with a + // filterless update, touch every row — both caught here.) + expect(await statusOf(bystander)).toBe('new'); + }); +}); diff --git a/packages/dogfood/test/rls-fixture.dogfood.test.ts b/packages/dogfood/test/rls-fixture.dogfood.test.ts index ed64e64e6c..cd7536a871 100644 --- a/packages/dogfood/test/rls-fixture.dogfood.test.ts +++ b/packages/dogfood/test/rls-fixture.dogfood.test.ts @@ -2,6 +2,12 @@ // // The HARD, revert-provable #1994 gate. // +// @proof: rls-by-id-write +// ADR-0054 runtime proof for the RLS / sharing high-risk class. Referenced by the +// liveness ledger entry `permission.rowLevelSecurity.using` +// (packages/spec/liveness/permission.json); the spec liveness gate fails if this +// tag is removed. See proof-registry.mts. +// // `auto-verify-rls.dogfood.test.ts` runs the cross-owner runner over the real // apps, but single-tenant boot strips the tenant policy so every object is // `member-visible` — the by-id-write path is never exercised. This test boots a diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 1b09eed2dd..3177793a41 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -35,6 +35,57 @@ Resolution per property: **ledger entry → spec `.describe()` marker → UNCLAS Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`, `protection` — ADR-0010) are auto-classified `live`. +## Runtime proofs — prove-it-runs (ADR-0054) + +`live` today means only *a static pointer to a consumer* — proof that something +*reads* the property. That is necessary but not sufficient: a property can be live +at every layer yet **broken end-to-end** (the break lives in the integration — +engine ↔ driver ↔ service ↔ HTTP). [ADR-0054](../../../docs/adr/0054-runtime-proof-for-authorable-surface.md) +adds the third leg: for a defined class of **high-risk** authorable properties, a +`live` entry must carry a **`proof`** — a reference to a `@objectstack/dogfood` test +that authors the property against the real in-process stack and asserts the runtime +outcome. + +```jsonc +"type": { + "status": "live", + "evidence": "packages/objectql/src/engine.ts", + "proof": "packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip" +} +``` + +**The contract.** A `proof` is `"#"`. The dogfood test +self-declares the id with a greppable tag near its top: + +```ts +// @proof: field-type-roundtrip +``` + +The gate validates **statically** (it never runs the test — that's the dogfood +gate's job, keeping this gate seconds-cheap): the file must exist **and** declare the +`@proof: ` tag. A bound entry must point at *its own class's* proof. The reverse +is also checked: a `@proof:` tag under `packages/dogfood/test/**` that isn't +registered in `../scripts/liveness/proof-registry.mts` is flagged (warning) so a new +proof gets wired in. + +**The ratchet (the authoritative high-risk-class list).** Defined in +`../scripts/liveness/proof-registry.mts`. A class is **CI-enforced** (`bound`) only +once it has *both* a runtime proof *and* a governed ledger entry to carry it — the +binding lands one class at a time (ADR-0054 §3), never as a big-bang backfill. + +| High-risk class | Bound? | Ledger binding | Proof | +|---|---|---|---| +| Field types | ✅ enforced | `field.type` | `field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip` | +| RLS / sharing | ✅ enforced | `permission.rowLevelSecurity.using` | `rls-fixture.dogfood.test.ts#rls-by-id-write` | +| Flow nodes | ✅ enforced | `flow.nodes.type` | `flow-node.dogfood.test.ts#flow-node-execution` | +| Analytics dims/measures | ✅ enforced | `dataset.dimensions.dateGranularity` | `analytics-timezone.dogfood.test.ts#analytics-tz-bucketing` | +| Form layout/section/widget | ⛔ pending | — | none yet (form surface not yet governed) | + +To bind a pending class: add its dogfood proof + `@proof:` tag, set `bound: true` and +its `ledgerBindings` in `proof-registry.mts`, add the `proof` to the ledger entry, and +confirm the gate is green. Because the gate also triggers on `packages/dogfood/**`, +deleting or renaming a proof re-runs this check and the dangling reference is caught. + ## Author warnings — closing the loop (`authorWarn`) Classification is also fed back to the *author* at build time. The CLI `compile` @@ -121,7 +172,8 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type | agent | 18 | 4 | 5 | access/permissions/visibility dead (chat route hardcodes perms); autonomy experimental | | tool | 13 | 1 | 5 | write-only metadata; runtime uses a parallel AIToolDefinition | | skill | 15 | – | 2 | triggerPhrases dead (no matcher); permissions dead | +| dataset | 26 | – | 1 | analytics semantic layer (compiled to a Cube); `measures.certified` dead; `dimensions.dateGranularity` carries the org-tz bucketing proof | The `dead` set across types is the enforce-or-remove worklist (ADR-0049). Not yet governed -(rollout): view, page, dashboard, app, report, dataset, job, datasource, translation, +(rollout): view, page, dashboard, app, report, job, datasource, translation, email_template, doc, book, validation, seed. diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json new file mode 100644 index 0000000000..5070f362de --- /dev/null +++ b/packages/spec/liveness/dataset.json @@ -0,0 +1,109 @@ +{ + "type": "dataset", + "_note": "DatasetSchema (analytics semantic layer). Consumer: @objectstack/service-analytics — compileDataset() turns a dataset into a Cube, DatasetExecutor runs the query. Seeded from a dataset-property consumer audit (file:line evidence in service-analytics). ADR-0054: dimensions.dateGranularity carries the org-timezone date-bucketing proof (#1982/#2018). Framework provenance/lock fields auto-live.", + "props": { + "name": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:178", + "note": "registry key + compiled Cube name." + }, + "label": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:179", + "note": "compiled into Cube title for presentations." + }, + "description": { + "status": "live", + "note": "display/doc metadata (presentations); not read by analytics execution." + }, + "object": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:180", + "note": "base table — Cube sql + join resolution + draft-rows resolver." + }, + "include": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:92", + "note": "compiled into Cube joins + the NativeSQLStrategy join allowlist (ADR-0021)." + }, + "filter": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-executor.ts:205", + "note": "dataset-level WHERE, ANDed with runtime + measure-scoped filters." + }, + "dimensions": { + "children": { + "name": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:141", + "note": "dimension identifier (Cube dimension key)." + }, + "label": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:142", + "note": "compiled into Cube dimension for presentations." + }, + "field": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:144", + "note": "emitted as the dimension's SQL column reference (relationship-validated)." + }, + "type": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:143", + "note": "dimensionType() maps to Cube time/number/boolean/string; drives grouping." + }, + "dateGranularity": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:146", + "proof": "packages/dogfood/test/analytics-timezone.dogfood.test.ts#analytics-tz-bucketing", + "note": "ADR-0054 high-risk class (analytics): a time dimension's single granularity auto-buckets at query time (dataset-executor.ts:272-287); the proof asserts the day bucket SHIFTS with the org timezone (2024-03-01T03:00Z buckets to 2024-02-29 in America/Los_Angeles) — guarding the analytics-strategy ↔ in-memory count ↔ REST exec-context integration that #2018 fixed." + } + } + }, + "measures": { + "children": { + "name": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:166", + "note": "measure identifier + derived-measure reference." + }, + "label": { + "status": "live", + "evidence": "packages/services/service-analytics/src/analytics-service.ts:479", + "note": "compiled into Cube metric + enriched onto result fields." + }, + "aggregate": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:60", + "note": "aggregateToMetricType() → Cube metric type (sum/count/avg/…); unsupported aggregates throw." + }, + "field": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-compiler.ts:170", + "note": "SQL aggregate operand (count omits field → '*'); relationship-validated." + }, + "filter": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-executor.ts:225", + "note": "measure-scoped WHERE, applied via a supplementary per-measure query." + }, + "format": { + "status": "live", + "evidence": "packages/services/service-analytics/src/analytics-service.ts:480", + "note": "compiled into Cube metric + enriched onto result fields for the renderer." + }, + "certified": { + "status": "dead", + "evidence": "no runtime consumer — analytics execution never reads it; not compiled into the Cube", + "note": "measure trust/governance flag, declared but unenforced (ADR-0049 enforce-or-remove candidate). Not authorWarn'd: it may surface as an objectui badge, so it is not necessarily misleading." + }, + "derived": { + "status": "live", + "evidence": "packages/services/service-analytics/src/dataset-executor.ts:247", + "note": "post-aggregation arithmetic (ratio/sum/difference/product) over base measures; {op, of} both consumed." + } + } + } + } +} diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 5f0d872a07..78a10dad33 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -12,7 +12,9 @@ }, "type": { "status": "live", - "evidence": "packages/objectql/src/engine.ts" + "evidence": "packages/objectql/src/engine.ts", + "proof": "packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip", + "note": "ADR-0054 high-risk class (field types): the proof writes one record of (almost) every field type over the real HTTP API and asserts each reads back with type fidelity (rating/slider→number, toggle→boolean) — guarding the persistence + read-coercion integration that #2025 fixed." }, "description": { "status": "live", diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index c39ef14f43..555d704128 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -64,7 +64,8 @@ "type": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", - "note": "open string vs live executor registry (ADR-0018); FlowNodeAction enum is out of sync." + "proof": "packages/dogfood/test/flow-node.dogfood.test.ts#flow-node-execution", + "note": "open string vs live executor registry (ADR-0018); FlowNodeAction enum is out of sync. ADR-0054 high-risk class (flow nodes): the proof authors a flow, triggers it over HTTP, and asserts the update_record node executed AND the input variable wired into its filter (only the targeted record changed) — guarding node execution + variable wiring, not just that the engine reads the node." }, "label": { "status": "live", diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 11fbe357f0..6b65dcb2f0 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -92,7 +92,8 @@ "using": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", - "note": "compiled into find + analytics SQL." + "proof": "packages/dogfood/test/rls-fixture.dogfood.test.ts#rls-by-id-write", + "note": "compiled into find + analytics SQL. ADR-0054 high-risk class (RLS): the proof boots an owner-isolated fixture so a fresh member cannot read an admin-created row, then asserts the runner's verdict in both directions — `rls-consistent` when the owner predicate also gates the by-id write (#1994 pre-image check) and `rls-hole` when it doesn't. Guards read AND by-id-write enforcement, not just the read predicate." }, "check": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 2ef597030f..62d7829cc1 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -22,6 +22,11 @@ // Statuses: live | experimental | planned | dead. Resolution per property: // ledger entry → spec `.describe()` marker ([EXPERIMENTAL — not enforced]) → UNCLASSIFIED // +// PROVE-IT-RUNS (ADR-0054): a `live` entry may carry a `proof` (a dogfood test +// reference `#`). For the HIGH-RISK classes bound this phase +// (see proof-registry.mts), a `live` classification MUST carry a valid proof — +// the file must exist and declare the `@proof: ` tag. CI fails otherwise. +// // Usage: // tsx check-liveness.mts # check all governed types // tsx check-liveness.mts --dump # inventory a type's properties (seeding aid) @@ -29,10 +34,18 @@ process.env.OS_EAGER_SCHEMAS = '1'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '../../src/kernel/metadata-type-schemas'; +import { + BOUND_PROOF_PATHS, + HIGH_RISK_CLASSES, + KNOWN_PROOF_IDS, + extractProofTags, + parseProofRef, + validateProofRef, +} from './proof-registry.mts'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); // packages/spec @@ -40,7 +53,7 @@ const repoRoot = resolve(specRoot, '../..'); const ledgerRoot = join(specRoot, 'liveness'); // Governed metadata types, rolled out highest-frequency / highest-risk first. -const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'role', 'agent', 'tool', 'skill']; +const GOVERNED = ['object', 'field', 'flow', 'action', 'hook', 'permission', 'role', 'agent', 'tool', 'skill', 'dataset']; // ADR-0010 provenance/lock overlay fields — system-stamped, on every type; auto-live. const FRAMEWORK_FIELDS = new Set([ @@ -131,7 +144,17 @@ if (dumpIdx !== -1) { // ---- check ---- const asJson = args.includes('--json'); -const report: any = { types: {}, totals: { byStatus: {} as Record }, unclassified: [] as string[], staleEvidence: [] as string[] }; +const report: any = { + types: {}, + totals: { byStatus: {} as Record }, + unclassified: [] as string[], + staleEvidence: [] as string[], + proofErrors: [] as string[], // a `proof` ref that doesn't resolve (missing file / missing tag / malformed) + proofMissing: [] as string[], // a bound high-risk `live` entry with no proof at all + orphanProofs: [] as string[], // a dogfood `@proof:` tag not registered in proof-registry.mts +}; + +const proofFs = { existsSync, readFileSync }; function classify(type: string, path: string, status: string, led: any, cat: any) { cat.classified++; @@ -141,6 +164,50 @@ function classify(type: string, path: string, status: string, led: any, cat: any const file = String(led.evidence).split(':')[0]; if (/\//.test(file) && !existsSync(join(repoRoot, file))) report.staleEvidence.push(`${type}/${path} → ${led.evidence}`); } + // ── ADR-0054 prove-it-runs ── + const boundClass = BOUND_PROOF_PATHS.get(`${type}/${path}`); + if (led?.proof !== undefined) { + // Any declared proof is validated (no silent rot), bound or not. + const v = validateProofRef(led.proof, { repoRoot, fs: proofFs, join }); + if (!v.ok) report.proofErrors.push(`${type}/${path} → ${v.error}`); + else if (boundClass) { + // A bound entry must point at ITS class's proof, not just any valid proof. + const id = parseProofRef(led.proof)?.id; + if (id !== boundClass.proofId) { + report.proofErrors.push( + `${type}/${path} → proof "${id}" is not the bound ${boundClass.label} proof ("${boundClass.proofId}")`, + ); + } + } + } else if (boundClass && status === 'live') { + report.proofMissing.push( + `${type}/${path} (${boundClass.label}) — high-risk live property requires a proof: expected "${boundClass.proofRef}"`, + ); + } +} + +// Reverse integrity: every `@proof:` tag declared under the dogfood proof tree +// must be registered in proof-registry.mts. An orphan tag means a proof was +// written but never wired into the high-risk-class list — flag it (warning). +function scanOrphanProofs() { + const proofDir = join(repoRoot, 'packages/dogfood/test'); + if (!existsSync(proofDir)) return; // spec may be consumed standalone (published) + const walk = (dir: string): string[] => { + const out: string[] = []; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, ent.name); + if (ent.isDirectory()) out.push(...walk(full)); + else if (ent.isFile() && ent.name.endsWith('.ts')) out.push(full); + } + return out; + }; + for (const file of walk(proofDir)) { + for (const tag of extractProofTags(readFileSync(file, 'utf8'))) { + if (!KNOWN_PROOF_IDS.has(tag)) { + report.orphanProofs.push(`@proof: ${tag} (in ${file.slice(repoRoot.length + 1)}) — not registered in proof-registry.mts`); + } + } + } } for (const type of GOVERNED) { @@ -169,7 +236,11 @@ for (const type of GOVERNED) { report.types[type] = cat; } +scanOrphanProofs(); + const totalUnclassified = report.unclassified.length; +const totalProofFailures = report.proofErrors.length + report.proofMissing.length; +const failed = totalUnclassified > 0 || totalProofFailures > 0; if (asJson) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); } else { @@ -178,15 +249,30 @@ if (asJson) { const parts = Object.entries(v.byStatus).map(([s, n]) => `${s} ${n}`).join(', '); console.log(` ${t.padEnd(11)} ${v.classified} classified (${parts || '—'})${v.unclassified ? `, ${v.unclassified} UNCLASSIFIED` : ''}`); } + const boundClasses = HIGH_RISK_CLASSES.filter((c) => c.bound).map((c) => c.label); + console.log(`\nprove-it-runs (ADR-0054): proof REQUIRED for bound high-risk classes — ${boundClasses.join(', ') || 'none'}`); if (report.staleEvidence.length) { console.log(`\n⚠ ${report.staleEvidence.length} 'live' entr(ies) cite a missing file:`); report.staleEvidence.forEach((s: string) => console.log(` ${s}`)); } + if (report.orphanProofs.length) { + console.log(`\n⚠ ${report.orphanProofs.length} unregistered dogfood proof tag(s) — add to proof-registry.mts:`); + report.orphanProofs.forEach((s: string) => console.log(` ${s}`)); + } + if (report.proofMissing.length) { + console.log(`\n✗ ${report.proofMissing.length} high-risk 'live' propert(ies) missing a runtime proof:`); + report.proofMissing.forEach((s: string) => console.log(` ${s}`)); + } + if (report.proofErrors.length) { + console.log(`\n✗ ${report.proofErrors.length} proof reference(s) do not resolve:`); + report.proofErrors.forEach((s: string) => console.log(` ${s}`)); + } if (totalUnclassified) { console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.json:`); report.unclassified.forEach((s: string) => console.log(` ${s}`)); - } else { - console.log('\n✓ all governed-type properties are classified.'); + } + if (!failed) { + console.log('\n✓ all governed-type properties are classified; all bound high-risk proofs resolve.'); } } -process.exit(totalUnclassified > 0 ? 1 : 0); +process.exit(failed ? 1 : 0); diff --git a/packages/spec/scripts/liveness/proof-registry.mts b/packages/spec/scripts/liveness/proof-registry.mts new file mode 100644 index 0000000000..df694931bd --- /dev/null +++ b/packages/spec/scripts/liveness/proof-registry.mts @@ -0,0 +1,179 @@ +// ADR-0054 — the prove-it-runs registry. +// +// ADR-0049 closed *false compliance* (a property declared but unenforced). The +// liveness ledger (#1919) then made every authorable property declare a status +// with evidence. But "live" means only "a consumer reads it" — a static pointer. +// ADR-0054 adds the third leg: for a defined class of HIGH-RISK authorable +// properties, a `live` classification must carry a **proof** — a reference to a +// `@objectstack/dogfood` test that authors the property against the real +// in-process stack and asserts the runtime outcome. +// +// This module is the single source of truth for: +// 1. the authoritative high-risk-class list (ADR-0054 §2a), and +// 2. which ledger entries are CI-ENFORCED to carry a proof this rollout phase +// (the ratchet lands one class at a time as its matrix is populated — §3). +// +// It is split out from check-liveness.mts so the proof contract is unit-testable +// without booting the metadata-type registry. + +/** A reference to a dogfood proof: `#`. */ +export type ProofRef = string; + +/** A ledger entry path the ratchet binds: `.` (one drill level). */ +export interface LedgerBinding { + type: string; // governed metadata type (e.g. 'field') + path: string; // property path within that type (e.g. 'type', 'rowLevelSecurity.using') +} + +export interface HighRiskClass { + /** Stable class id (ADR-0054 §2a class). */ + id: string; + /** Human label. */ + label: string; + /** The end-to-end break this class guards — why a static consumer pointer is insufficient. */ + summary: string; + /** Canonical `@proof:` tag id the dogfood test self-declares. */ + proofId: string; + /** The proof reference (`file#id`), or null when the class has no runtime proof yet. */ + proofRef: ProofRef | null; + /** + * Whether the ratchet is CI-ENFORCED for this class THIS phase. ADR-0054 §3: + * "CI binding lands incrementally … one class at a time as its matrix is + * populated." A class is `bound` only once it has BOTH a runtime proof AND a + * governed ledger entry to carry it. + */ + bound: boolean; + /** Ledger entries that must carry `proofRef` when their status is `live`. */ + ledgerBindings: LedgerBinding[]; + /** When `bound` is false, why — kept honest rather than faking a binding. */ + blockedReason?: string; +} + +// The authoritative high-risk-class list (ADR-0054 §2a). Classes whose values +// cross the engine↔driver↔service↔HTTP boundary and have repeatedly broken in +// *integration* despite green unit tests. Two are bound this phase (their matrix +// exists AND their surface is governed); three are listed-but-blocked, honestly. +export const HIGH_RISK_CLASSES: HighRiskClass[] = [ + { + id: 'field-type', + label: 'Field types', + summary: + 'persistence + read-coercion fidelity across the field-type matrix (write 4 → read 4, not "4").', + proofId: 'field-type-roundtrip', + proofRef: 'packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts#field-type-roundtrip', + bound: true, + ledgerBindings: [{ type: 'field', path: 'type' }], + }, + { + id: 'rls-sharing', + label: 'RLS / sharing', + summary: + "row-level read AND by-id-write enforcement (the #1994 can't-write-what-you-can't-read invariant).", + proofId: 'rls-by-id-write', + proofRef: 'packages/dogfood/test/rls-fixture.dogfood.test.ts#rls-by-id-write', + bound: true, + // `using` is the read predicate the #1994 fix re-applies as a by-id-write + // pre-image check — the property whose end-to-end correctness the proof guards. + ledgerBindings: [{ type: 'permission', path: 'rowLevelSecurity.using' }], + }, + { + id: 'analytics', + label: 'Analytics dimensions / measures', + summary: 'date-dimension bucketing / aggregation under the org timezone (#1982/#2018).', + proofId: 'analytics-tz-bucketing', + proofRef: 'packages/dogfood/test/analytics-timezone.dogfood.test.ts#analytics-tz-bucketing', + bound: true, + // The org-timezone shift acts on a time dimension's bucketing granularity — + // the property whose end-to-end correctness the tz proof guards. + ledgerBindings: [{ type: 'dataset', path: 'dimensions.dateGranularity' }], + }, + { + id: 'flow-node', + label: 'Flow nodes', + summary: 'node execution + variable wiring through the automation engine.', + proofId: 'flow-node-execution', + proofRef: 'packages/dogfood/test/flow-node.dogfood.test.ts#flow-node-execution', + bound: true, + // `nodes.type` selects which executor runs — the property whose end-to-end + // execution + variable wiring the proof guards. + ledgerBindings: [{ type: 'flow', path: 'nodes.type' }], + }, + { + id: 'form-widget', + label: 'Form layout / section / widget', + summary: 'server-side form resolution.', + proofId: 'form-widget-resolution', + proofRef: null, + bound: false, + ledgerBindings: [], + blockedReason: + 'the form layout/section/widget surface is not yet governed and has no runtime proof (ADR-0054 Phase 2).', + }, +]; + +/** Bound ledger paths → the class that binds them. Key: `/`. */ +export const BOUND_PROOF_PATHS: Map = (() => { + const m = new Map(); + for (const cls of HIGH_RISK_CLASSES) { + if (!cls.bound) continue; + for (const b of cls.ledgerBindings) m.set(`${b.type}/${b.path}`, cls); + } + return m; +})(); + +/** Every proof id the registry knows about (bound + pending) — used to flag orphan tags. */ +export const KNOWN_PROOF_IDS: Set = new Set(HIGH_RISK_CLASSES.map((c) => c.proofId)); + +/** Parse a proof reference into its file + id parts, or null if malformed. */ +export function parseProofRef(ref: unknown): { file: string; id: string } | null { + if (typeof ref !== 'string') return null; + const hash = ref.indexOf('#'); + if (hash <= 0 || hash >= ref.length - 1) return null; + const file = ref.slice(0, hash).trim(); + const id = ref.slice(hash + 1).trim(); + if (!file || !id) return null; + return { file, id }; +} + +// A proof tag a dogfood test self-declares, e.g. `// @proof: field-type-roundtrip`. +// Greppable + stable across test-title churn (field-zoo titles are generated in a +// loop), which is why we match a tag rather than a test name. +const PROOF_TAG_RE = /@proof:\s*([a-z0-9][a-z0-9-]*)/g; + +/** Collect all `@proof:` tag ids declared in a file's text. */ +export function extractProofTags(content: string): Set { + const out = new Set(); + for (const m of content.matchAll(PROOF_TAG_RE)) out.add(m[1]); + return out; +} + +/** Minimal fs surface so validation is unit-testable without touching disk. */ +export interface ProofFs { + existsSync(path: string): boolean; + readFileSync(path: string, enc: 'utf8'): string; +} + +export interface ProofValidation { + ok: boolean; + error?: string; +} + +/** + * Validate that a proof reference resolves to a real, named proof: the file + * exists AND declares the `@proof: ` tag. STATIC ONLY — it never runs the + * test (that is the dogfood gate's job); the liveness gate stays seconds-cheap. + */ +export function validateProofRef( + ref: unknown, + opts: { repoRoot: string; fs: ProofFs; join: (...parts: string[]) => string }, +): ProofValidation { + const parsed = parseProofRef(ref); + if (!parsed) return { ok: false, error: `malformed proof ref (expected "#"): ${String(ref)}` }; + const abs = opts.join(opts.repoRoot, parsed.file); + if (!opts.fs.existsSync(abs)) return { ok: false, error: `proof file not found: ${parsed.file}` }; + const content = opts.fs.readFileSync(abs, 'utf8'); + if (!extractProofTags(content).has(parsed.id)) { + return { ok: false, error: `proof tag "@proof: ${parsed.id}" not found in ${parsed.file}` }; + } + return { ok: true }; +} diff --git a/packages/spec/scripts/liveness/proof-registry.test.ts b/packages/spec/scripts/liveness/proof-registry.test.ts new file mode 100644 index 0000000000..e35c054a37 --- /dev/null +++ b/packages/spec/scripts/liveness/proof-registry.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit + wiring tests for the ADR-0054 prove-it-runs proof contract. + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { + HIGH_RISK_CLASSES, + BOUND_PROOF_PATHS, + KNOWN_PROOF_IDS, + parseProofRef, + extractProofTags, + validateProofRef, +} from './proof-registry.mts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '../../../..'); // packages/spec/scripts/liveness → repo root + +describe('parseProofRef', () => { + it('splits a well-formed ref into file + id', () => { + expect(parseProofRef('packages/dogfood/test/x.test.ts#my-proof')).toEqual({ + file: 'packages/dogfood/test/x.test.ts', + id: 'my-proof', + }); + }); + + it('rejects malformed refs', () => { + expect(parseProofRef('no-hash-here.ts')).toBeNull(); + expect(parseProofRef('#leading')).toBeNull(); + expect(parseProofRef('trailing#')).toBeNull(); + expect(parseProofRef('')).toBeNull(); + expect(parseProofRef(42 as unknown)).toBeNull(); + expect(parseProofRef(undefined)).toBeNull(); + }); +}); + +describe('extractProofTags', () => { + it('finds every @proof tag in text and ignores non-matches', () => { + const tags = extractProofTags('// @proof: alpha\nnoise\n/* @proof: beta-1 */ @proof:notALabel?'); + expect(tags.has('alpha')).toBe(true); + expect(tags.has('beta-1')).toBe(true); + // `notALabel` is matched up to the first non-id char — fine; the point is no false negatives. + expect(tags.has('alpha-missing')).toBe(false); + }); + + it('returns empty when there are no tags', () => { + expect(extractProofTags('just some code').size).toBe(0); + }); +}); + +describe('validateProofRef (injected fs)', () => { + const fakeJoin = (...p: string[]) => p.join('/'); + + it('passes when the file exists and declares the tag', () => { + const fs = { + existsSync: () => true, + readFileSync: () => '// @proof: good-proof', + }; + expect(validateProofRef('a/b.test.ts#good-proof', { repoRoot: '/root', fs, join: fakeJoin })).toEqual({ ok: true }); + }); + + it('fails on a malformed ref', () => { + const fs = { existsSync: () => true, readFileSync: () => '' }; + const r = validateProofRef('nohash', { repoRoot: '/root', fs, join: fakeJoin }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/malformed/); + }); + + it('fails when the proof file is missing', () => { + const fs = { existsSync: () => false, readFileSync: () => '' }; + const r = validateProofRef('a/b.test.ts#x', { repoRoot: '/root', fs, join: fakeJoin }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/file not found/); + }); + + it('fails when the file exists but the tag is absent (rot)', () => { + const fs = { existsSync: () => true, readFileSync: () => '// @proof: other' }; + const r = validateProofRef('a/b.test.ts#x', { repoRoot: '/root', fs, join: fakeJoin }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/not found in/); + }); +}); + +describe('registry invariants', () => { + it('bound classes carry a proofRef whose id matches the class proofId', () => { + for (const cls of HIGH_RISK_CLASSES) { + if (!cls.bound) continue; + expect(cls.proofRef, `${cls.id} is bound but has no proofRef`).toBeTruthy(); + expect(parseProofRef(cls.proofRef)?.id).toBe(cls.proofId); + expect(cls.ledgerBindings.length, `${cls.id} is bound but binds no ledger entry`).toBeGreaterThan(0); + } + }); + + it('unbound classes record an honest blockedReason', () => { + for (const cls of HIGH_RISK_CLASSES) { + if (cls.bound) continue; + expect(cls.blockedReason, `${cls.id} is unbound without a blockedReason`).toBeTruthy(); + } + }); + + it('BOUND_PROOF_PATHS maps the expected entries this phase', () => { + expect([...BOUND_PROOF_PATHS.keys()].sort()).toEqual( + [ + 'field/type', + 'flow/nodes.type', + 'permission/rowLevelSecurity.using', + 'dataset/dimensions.dateGranularity', + ].sort(), + ); + }); + + it('every class proofId is in KNOWN_PROOF_IDS', () => { + for (const cls of HIGH_RISK_CLASSES) expect(KNOWN_PROOF_IDS.has(cls.proofId)).toBe(true); + }); +}); + +// End-to-end wiring: the REAL ledger entries reference the REAL dogfood proofs, +// and those proofs declare the matching tag. This is the check that the spec gate +// runs in CI, asserted here without booting the metadata-type registry. +describe('real proof wiring resolves', () => { + const fs = { existsSync, readFileSync }; + const ledgerFor: Record = { + field: 'packages/spec/liveness/field.json', + permission: 'packages/spec/liveness/permission.json', + flow: 'packages/spec/liveness/flow.json', + dataset: 'packages/spec/liveness/dataset.json', + }; + + function ledgerEntry(type: string, path: string): any { + const ledger = JSON.parse(readFileSync(join(repoRoot, ledgerFor[type]), 'utf8')); + let node = ledger.props; + const segs = path.split('.'); + for (let i = 0; i < segs.length; i++) { + node = i === 0 ? node[segs[i]] : node.children?.[segs[i]]; + expect(node, `missing ledger node ${type}.${segs.slice(0, i + 1).join('.')}`).toBeTruthy(); + } + return node; + } + + for (const cls of HIGH_RISK_CLASSES.filter((c) => c.bound)) { + for (const b of cls.ledgerBindings) { + it(`${b.type}.${b.path} carries the ${cls.id} proof and it resolves`, () => { + const entry = ledgerEntry(b.type, b.path); + expect(entry.status).toBe('live'); + expect(entry.proof, `${b.type}.${b.path} missing proof`).toBe(cls.proofRef); + expect(validateProofRef(entry.proof, { repoRoot, fs, join })).toEqual({ ok: true }); + }); + } + } + + it('no bound class is left with an unresolved proof or a blockedReason', () => { + for (const cls of HIGH_RISK_CLASSES.filter((c) => c.bound)) { + expect(cls.blockedReason, `${cls.id} is bound but still records a blockedReason`).toBeUndefined(); + expect(validateProofRef(cls.proofRef, { repoRoot, fs, join })).toEqual({ ok: true }); + } + }); +}); diff --git a/packages/spec/vitest.config.ts b/packages/spec/vitest.config.ts index a14933df1c..b5ba99a3b8 100644 --- a/packages/spec/vitest.config.ts +++ b/packages/spec/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/packages/verify/package.json b/packages/verify/package.json index 0aa0da006e..a89f25e664 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -31,7 +31,8 @@ "@objectstack/plugin-sharing": "workspace:*", "@objectstack/plugin-org-scoping": "workspace:*", "@objectstack/service-settings": "workspace:*", - "@objectstack/service-analytics": "workspace:*" + "@objectstack/service-analytics": "workspace:*", + "@objectstack/service-automation": "workspace:*" }, "devDependencies": { "@types/node": "^25.9.3", diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 96be31bccf..9415674191 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -83,6 +83,16 @@ export interface BootOptions { * stripped and a member sees every row. Default `false`. */ multiTenant?: boolean; + /** + * Register `@objectstack/service-automation` so authored flows execute against + * the real stack. The plugin seeds the built-in node executors and, at start(), + * pulls every flow in the app config from the ObjectQL registry and registers + * it — so `POST /api/v1/automation/:name/trigger` actually runs the flow's + * nodes. Without this the dispatcher's automation routes resolve no `automation` + * service and flow execution is unreachable. Opt-in (like `multiTenant`) so the + * default boot stays lean for apps that don't exercise flows. Default `false`. + */ + automation?: boolean; } /** @@ -126,6 +136,15 @@ export async function bootStack( await kernel.use(new OrgScopingPlugin()); } + // Automation service — opt-in. Registered before bootstrap so its start() + // phase pulls the app's flows from the ObjectQL registry (populated by + // AppPlugin.init) and registers them. `memory` suspended-run store keeps the + // harness free of any manifest/persistence dependency for flow execution. + if (opts.automation) { + const { AutomationServicePlugin } = await import('@objectstack/service-automation'); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + } + await kernel.use(opts.security ?? new SecurityPlugin()); // Sharing service — apps that declare `requires: ['sharing']` rely on it for // record-share grants; without it their RLS/sharing rules are inert and the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f2f4ba4ab..40a496ed9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2218,6 +2218,9 @@ importers: '@objectstack/service-analytics': specifier: workspace:* version: link:../services/service-analytics + '@objectstack/service-automation': + specifier: workspace:* + version: link:../services/service-automation '@objectstack/service-settings': specifier: workspace:* version: link:../services/service-settings