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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
68 changes: 68 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2148,6 +2148,74 @@ 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.
#
# ⛔ AND THE PRODUCTION SWEEP IS A SEPARATE STEP, which is not cosmetic.
# `check-step-collectors` drives this very block under a real `bash -e`
# with a STUB planted at each collected command's script path, and holds
# "all green => every command runs" as an equality. A trailing
# `node scripts/check-adr-symbol-anchors.mjs` in the same block re-invokes
# that same stub, so the harness counts 3 executions against 2 collected
# commands and the gate reds (3/2) — which is exactly what it did here.
# Both existing collectors in this file carry self-tests ONLY, for the
# same reason. Keep the sweep out of the collector block.
- name: ADR symbol-anchor gate self-tests
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

- name: ADR symbol anchors resolve (no line numbers survive)
run: 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
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0005-metadata-customization-overlay.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/<name>` 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/<name>` 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/<name>` 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.

Expand DownExpand Up@@ -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`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0015-external-datasource-federation.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0016-studio-package-authoring-and-publish.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'`,
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)).
Expand DownExpand Up@@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0020-state-machine-converge-and-enforce.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, StateMachineSchema>` ("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<string, StateMachineSchema>` ("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` <!-- anchor-exempt: HISTORICAL --> — 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` <!-- anchor-exempt: HISTORICAL --> (`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

Expand DownExpand Up@@ -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

Expand Down
Loading
Loading