Skip to content

Commit fd9664f

Browse files
fix(app-showcase): ship the approver-bindings specimen obsolete, not draft (#24008)
Fixes #23965 Clause-②: no ## What changed `showcase_approver_bindings` is the designer specimen for the record-backed approver kinds. Its approval nodes resolve to nobody on a fresh org. It shipped as `status: 'draft'` on the belief that draft "keeps it out of the runtime". That belief is false: the engine disables a flow only on `obsolete` / `invalid`, and it binds a draft flow exactly like an `active` one. So every `showcase_field_zoo` update started a run, and each run parked a pending `admin_rescue` approval request on the org administrators. - `examples/app-showcase/src/automation/flows/approver-bindings.flow.ts`: `status: 'obsolete'`, which is the platform's declared "loaded, not armed" status. The docblock's `## Why draft` section is now `## Why obsolete` and states the real mechanism: draft fires, and obsolete loads, lists and opens but is never handed to a trigger. The author-visible `description` drops "Draft on purpose" in the same way. - `examples/app-showcase/src/automation/flows/index.ts:2012-2014`: the registration comment said "Draft on purpose — see the module docstring". It now says "Obsolete on purpose (loaded, never armed)". This is a bounded in-place fix outside the claim's declared file surface; see Acceptance notes. - `examples/app-showcase/test/approver-bindings-specimen-unarmed.test.ts` (new): a real-engine pin with a `draft` positive control. No `draft` semantics changed: nothing under `packages/**` is touched, and neither is the checklist ledger. ## The deciding read: does the designer still open an `obsolete` flow? Yes. Measured at objectstack `074b33be1b` and objectui `f8267df93a` (the `.objectui-sha` pin): - **Engine** (`packages/services/service-automation/src/engine.ts`): `registerFlow` stores the flow (`:4695` `this.flows.set(name, parsed)`) before it reads status. `:4718` then disables only `obsolete` / `invalid`. `listFlows` / `getFlow` (`:5040-5046`) answer from that map whatever the status. The one arming gate is `activateFlowTrigger` `:3932` (`if (!this.isFlowEnabled(flowName)) return;`), and `execute` refuses a disabled flow with `FLOW_DISABLED`. The boot line is at `plugin.ts:1627`, and the lint rule names the same semantics at `validate-flow-trigger-readiness.ts:129` and `:830`. - **Metadata / REST**: `packages/metadata/src`, `packages/metadata-protocol/src`, `packages/rest/src` and `packages/objectql/src` carry no `obsolete` filter (`git grep` returns zero hits). The only runtime hits are prose in `packages/runtime/src/domains/automation.ts` and `flow-clone.ts`. - **Studio designer** (objectui pin): `StudioDesignSurface.tsx:7008-7024` creates every new flow with `status: 'obsolete'` ("born switched OFF", objectui#11779). Opening an obsolete flow is therefore the designer's own default path. `:7146-7165` renders the on/off switch with obsolete as Off. The rail lists `client.list(type, { packageId })` (`packageSurfaces.ts:71`) with no status filter. ## Evidence **Pin + ablation.** `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2 test/approver-bindings-specimen-unarmed.test.ts` gives 4/4 passed. The pin registers the shipped definition on a real `AutomationEngine` with a recording `record_change` trigger, and asserts four things: - the flow is held and listed; - no binding reached the trigger; - `getFlowRuntimeStates()` reads `enabled: false, bound: false, status: 'obsolete'`; - a direct `execute` answers `success: false, code: 'FLOW_DISABLED'` and records no run. The positive control registers the same definition under `draft` and must see it bound to `showcase_field_zoo` / `record-after-update`. Ablation, one run: `node scripts/ablation-replace.mjs` put `status: 'draft'` back (anchor x1 to x0, blob `e4ef4d4723` to `20712cc37a`) and the result was **2 failed / 2 passed**. The specimen showed up in the trigger's bindings, and `execute` did not answer `FLOW_DISABLED`. The control and the membership cases stayed green. Restore: blob after restore `e4ef4d4723` == HEAD, and `git diff HEAD` was empty. The specimen module is imported by relative path (source), so no `dist/` sits between the mutation and the run. **Live, public HTTP door.** A fresh boot: `pnpm dev -- --fresh -p 47391 --log-level info`, at this branch after a full `pnpm build`. - Boot log: `[Automation] 8 flow(s) have status 'draft' (...)`. The card measured 9, and the specimen is no longer in that list. `Flow registered: showcase_approver_bindings (version 1)` is still printed. No `[record-change] bound flow` line names `showcase_field_zoo`, while the same log carries that line for other flows. - `GET /api/v1/meta/flow/showcase_approver_bindings` returns 200 with `status: obsolete` and 9 nodes. `GET /api/v1/meta/flow` returns 32 items, and the specimen is listed. - `GET /api/v1/automation/_status` returns the row `enabled: false, bound: false, status: obsolete, triggerType: record_change, object: showcase_field_zoo`. - `PATCH /api/v1/data/showcase_field_zoo/ID` with `{"f_email":"qa23965@example.com"}` on "Specimen — Full" returns 200. More than 3 s later: - `GET /api/v1/automation/showcase_approver_bindings/runs` returns `runs: []`; - `sys_automation_run` filtered on the flow returns `total: 0`; - `sys_approval_request` filtered on `process_name: flow:showcase_approver_bindings` returns `total: 0`; - the log has 0 `admin_rescue` lines. - Control on the same boot and the same door: a task PATCHed to `priority: urgent` gives `sys_automation_run` `total 1`, `showcase_urgent_task_alert` `completed`. ## Tests and gates (head `074b33be1b`) - Closure build: `turbo run build --filter='@objectstack/example-showcase^...' --concurrency=2` passed 62/62. A full `pnpm build` passed 75/75; the dev boot's prereq check needed it. - `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2`: 33 files / 415 tests passed. `pnpm --filter @objectstack/example-showcase typecheck` (`tsc --noEmit`, which includes `test/**/*`) exited 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 42 commands. All 42 ran and each exited 0 (exit captured before any pipe). `--ran` reports: 42 derived, 42 run, 0 NOT-MEASURED, 0 UNRUN. - `test:repo` is not touched and is left to CI (`Test Core`). ## Acceptance notes - **Surface supplement, a bounded in-place fix.** The `index.ts:2012-2014` comment repeated the "Draft on purpose" premise and would have become false with this change. Why the in-place fix applies: - it is the same defect class; - it is a mechanical one-word change; - no other open PR touches `examples/app-showcase/src/automation/` (per the claim); - it adds no verification surface. The claim's file surface named only the flow file, the pin and a changeset. - **No changeset.** `examples/app-showcase` is `"private": true` and publishes nothing, so this PR carries `skip-changeset`. - **Checklist premise, the ledger owner's, not in this diff.** Three lines still describe the specimen as draft: - `docs/qa/platform-checklist/areas/automation.json:985` ("showcase_approver_bindings is draft on purpose"); - `docs/qa/platform-checklist/areas/approvals.json:1224` ("approver-bindings.flow.ts is status:'draft' with EMPTY values, deliberately"); - `docs/qa/platform-checklist/areas/approvals.json:1290` ("the #3508 DESIGNER specimen — draft, empty values"). The conclusions those notes draw still hold, now for the declared reason: no armed record-change flow on Field Zoo, and runtime legs need scratch flows. Only the status word goes stale. The card routes these to the ledger owner. Once this PR closes the card, there is no other carrier. - The specimen's `label` still reads `Approver Binding Specimens (#3508)`, an author-visible string carrying a tracker number. That predates this PR and is left as it is (naming). --- _Generated by [Claude Code](https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19955fb commit fd9664f

3 files changed

Lines changed: 133 additions & 9 deletions

File tree

‎examples/app-showcase/src/automation/flows/approver-bindings.flow.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,28 @@ import { defineFlow } from '@objectstack/spec';
2323
* covers `org_membership_level` + `expression`; the kinds below are the ones no
2424
* other flow declares.
2525
*
26-
* ## Why `draft`
26+
* ## Why `obsolete`
2727
*
2828
* Deliberate, not an oversight. The record-backed kinds point at rows a fresh
2929
* boot does not have: `sys_user` holds only the seeded admin, `sys_team` is
3030
* empty (the seeded `Team` rows are the DEMO `showcase_team` object), and both
3131
* unit membership (`sys_business_unit_member`) and position assignment
3232
* (`sys_user_position`) are runtime admin actions by design — see the seed
33-
* notes in `src/data/seed/index.ts`. An `active` flow over them would resolve
34-
* to nobody, which is precisely the silent dead slot #3508 exists to remove.
35-
* Shipping that as a demo would re-teach the bug. `draft` keeps it out of the
36-
* runtime while still loading in the designer, which is the surface under test.
33+
* notes in `src/data/seed/index.ts`. An armed flow over them would resolve to
34+
* nobody, which is precisely the silent dead slot #3508 exists to remove: every
35+
* Field Zoo update would start a run that parks at `by_user` and, under
36+
* `onEmptyApprovers: 'admin_rescue'`, files a pending approval request on the
37+
* organization's administrators. Shipping that as a demo would re-teach the bug.
38+
*
39+
* `obsolete` is the status the platform declares for "loaded, not armed". The
40+
* engine still registers the flow — the designer lists and opens it, which is
41+
* the surface under test, and Studio's on/off switch reads it as Off — but it
42+
* never hands the flow to a trigger and refuses a direct start with
43+
* `FLOW_DISABLED`. ⛔ Not `draft`: a draft flow binds and fires exactly like an
44+
* `active` one (only `obsolete` / `invalid` disable — `registerFlow` in
45+
* service-automation's engine), which is why every boot logs the draft flows
46+
* by name and `os validate` flags them `flow-draft-status-ambiguous`. Pinned by
47+
* `test/approver-bindings-specimen-unarmed.test.ts`.
3748
*
3849
* `queue` is absent on purpose: #3536 made it non-authorable
3950
* (`NON_AUTHORABLE_APPROVER_TYPES` → `xEnumDeprecated`) because the engine has
@@ -44,9 +55,10 @@ export const ApproverBindingsFlow = defineFlow({
4455
label: 'Approver Binding Specimens (#3508)',
4556
description:
4657
'Authoring specimen: one approval node per record-backed approver kind, so the designer '
47-
+ 'renders each Value control. Draft on purpose — a fresh org has no rows for these kinds.',
58+
+ 'renders each Value control. Obsolete on purpose (loaded, never armed) — a fresh org has '
59+
+ 'no rows for these kinds, so an armed run would park on the org administrators.',
4860
type: 'autolaunched',
49-
status: 'draft',
61+
status: 'obsolete',
5062
nodes: [
5163
{
5264
id: 'start',

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,8 +2010,8 @@ export const allFlows = [
20102010
// #3447 P2 dogfood: expression approvers + decision outputs, end to end.
20112011
DynamicApprovalFlow,
20122012
// #3508 dogfood: one approval node per record-backed approver kind, so the
2013-
// designer's Value control has a specimen for each. Draft on purpose — see
2014-
// the module docstring.
2013+
// designer's Value control has a specimen for each. Obsolete on purpose
2014+
// (loaded, never armed) — see the module docstring.
20152015
ApproverBindingsFlow,
20162016
// The recalc behind the two `api` actions, served by the declared
20172017
// `showcase_recalc_estimates_api` endpoint (src/system/apis/).
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23965] `showcase_approver_bindings` is a DESIGNER specimen: it loads, and
5+
* it is never armed.
6+
*
7+
* Its approval nodes carry empty values over directory kinds a fresh org has
8+
* no rows for, so an armed copy resolves to nobody — and, under
9+
* `onEmptyApprovers: 'admin_rescue'`, every Field Zoo update starts a run that
10+
* parks a pending approval request on the org administrators. It used to ship
11+
* as `status: 'draft'` on the belief that draft keeps a flow out of the
12+
* runtime. It does not: the engine disables a flow on `obsolete` / `invalid`
13+
* only, and binds a draft flow exactly like an `active` one.
14+
*
15+
* This is an ENGINE reading, not a reading of the `status` literal. A literal
16+
* check stays green on any spelling, including one the engine happens not to
17+
* disable, which is exactly how `draft` shipped. So the shipped definition —
18+
* the module's own export, which the first case proves is the very object
19+
* `allFlows` loads, not a copy — is registered on
20+
* a real `AutomationEngine` with a recording `record_change` trigger standing
21+
* in for `@objectstack/trigger-record-change` (whose `start()` is what
22+
* subscribes a flow to the object's write hooks), and the pin asserts what the
23+
* engine did with it:
24+
*
25+
* - it still LOADS: the engine holds the definition and lists it, which is
26+
* what the metadata door and the designer read;
27+
* - it is NOT ARMED: the trigger was never handed a binding for it, so an
28+
* update to `showcase_field_zoo` has nothing to call;
29+
* - a direct start is refused with `FLOW_DISABLED` and records no run.
30+
*
31+
* The positive control registers the SAME definition under `draft` and must
32+
* see it bound to `showcase_field_zoo` / `record-after-update`. Without it, a
33+
* harness whose trigger never binds anything would green every assertion here.
34+
*/
35+
36+
import { describe, it, expect } from 'vitest';
37+
import { AutomationEngine } from '@objectstack/service-automation';
38+
import type { FlowTrigger, FlowTriggerBinding } from '@objectstack/service-automation';
39+
40+
import { ApproverBindingsFlow } from '../src/automation/flows/approver-bindings.flow.js';
41+
import { allFlows } from '../src/automation/flows/index.js';
42+
43+
const SPECIMEN = 'showcase_approver_bindings';
44+
45+
function silentLogger(): any {
46+
const logger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
47+
logger.child = () => logger;
48+
return logger;
49+
}
50+
51+
/** A `record_change` trigger that records every binding the engine hands it. */
52+
function recordingRecordChangeTrigger() {
53+
const started: FlowTriggerBinding[] = [];
54+
const trigger: FlowTrigger = {
55+
type: 'record_change',
56+
start(binding) {
57+
started.push(binding);
58+
},
59+
stop() {},
60+
};
61+
return { trigger, started };
62+
}
63+
64+
function engineWithTrigger() {
65+
const engine = new AutomationEngine(silentLogger());
66+
const rec = recordingRecordChangeTrigger();
67+
engine.registerTrigger(rec.trigger);
68+
return { engine, started: rec.started };
69+
}
70+
71+
describe('#23965 — showcase_approver_bindings loads but is never armed', () => {
72+
it('is part of the app, so the designer has it to open', () => {
73+
expect(ApproverBindingsFlow.name).toBe(SPECIMEN);
74+
expect(allFlows).toContain(ApproverBindingsFlow);
75+
});
76+
77+
it('positive control: the same definition under `draft` IS bound to Field Zoo updates', () => {
78+
const { engine, started } = engineWithTrigger();
79+
const control = `${SPECIMEN}_draft_control`;
80+
engine.registerFlow(control, { ...ApproverBindingsFlow, name: control, status: 'draft' } as never);
81+
82+
expect(started.map((b) => ({ flowName: b.flowName, object: b.object, event: b.event }))).toEqual([
83+
{ flowName: control, object: 'showcase_field_zoo', event: 'record-after-update' },
84+
]);
85+
});
86+
87+
it('the shipped definition registers and lists, but is handed to no trigger', async () => {
88+
const { engine, started } = engineWithTrigger();
89+
engine.registerFlow(SPECIMEN, ApproverBindingsFlow as never);
90+
91+
// Loads: the engine holds and lists it.
92+
expect(await engine.listFlows()).toContain(SPECIMEN);
93+
expect(await engine.getFlow(SPECIMEN)).not.toBeNull();
94+
95+
// Not armed: no binding reached the record-change trigger, so a Field Zoo
96+
// update has no callback to run it through.
97+
expect(started.map((b) => b.flowName)).not.toContain(SPECIMEN);
98+
expect(engine.getActiveTriggerBindings().map((b) => b.flowName)).not.toContain(SPECIMEN);
99+
100+
const state = engine.getFlowRuntimeStates().find((s) => s.name === SPECIMEN);
101+
expect(state).toMatchObject({ enabled: false, bound: false, status: 'obsolete' });
102+
});
103+
104+
it('a direct start is refused with FLOW_DISABLED and records no run', async () => {
105+
const { engine } = engineWithTrigger();
106+
engine.registerFlow(SPECIMEN, ApproverBindingsFlow as never);
107+
108+
const result = await engine.execute(SPECIMEN);
109+
expect(result).toMatchObject({ success: false, code: 'FLOW_DISABLED' });
110+
expect(await engine.listRuns(SPECIMEN)).toEqual([]);
111+
});
112+
});

0 commit comments

Comments
 (0)