Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/liveness-prove-it-runs.md
Original file line number Diff line number Diff line change
@@ -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 (`<file>#<proof-id>`) 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`.
5 changes: 5 additions & 0 deletions .changeset/verify-automation-flow-proofs.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .github/workflows/spec-liveness-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ name: Spec Liveness Check
# property of a GOVERNED type to declare a liveness status with evidence in
# packages/spec/liveness/<type>.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
Expand Down
6 changes: 6 additions & 0 deletions packages/dogfood/test/analytics-timezone.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions packages/dogfood/test/fixtures/flow-touch-fixture.ts
Original file line number Diff line number Diff line change
@@ -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],
});
80 changes: 80 additions & 0 deletions packages/dogfood/test/flow-node.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<unknown> {
const res = await stack.apiAs(token, 'GET', `/data/flow_note/${id}`);
expect(res.status).toBe(200);
const j = (await res.json()) as { record?: Record<string, unknown> } & Record<string, unknown>;
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');
});
});
6 changes: 6 additions & 0 deletions packages/dogfood/test/rls-fixture.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion packages/spec/liveness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<repo-relative-file>#<proof-id>"`. 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: <id>` 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`
Expand Down Expand Up @@ -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.
Loading
Loading