Skip to content

Commit fbd7b4d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23889-approval-display-labels
2 parents f215e09 + bb14934 commit fbd7b4d

11 files changed

Lines changed: 1239 additions & 19 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): an `os serve` boot that composes no HTTP server now stays up until it is stopped, as its banner's `Press Ctrl+C to stop` says
6+
7+
Clause-②: no
8+
9+
- **Before.** `os serve --no-server --preset minimal`, in production with no storage driver (no `OS_DATABASE_URL`), printed the ready banner ending `Press Ctrl+C to stop` and then exited 0 by itself, about 50 ms later, with no signal and without the kernel shutting down. The same command stayed up whenever the storage driver kept a connection pool (`--dev --no-server`, or `--no-server` with `OS_DATABASE_URL` set). Measured, that pool's reaping interval was the only thing keeping those boots alive, so whether a kernel-only boot stayed up depended on the driver.
10+
- **After, for an operator or supervisor of a boot with no HTTP server composed** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). The process stays up after its banner, whatever the driver, until SIGINT, SIGTERM or SIGQUIT. The kernel's own signal handling then drains it and exits with the kernel's code, 0 on a clean shutdown, as on a server boot. No signal listener is added.
11+
- **Why "stay up" and not "run and exit".** The banner says `Press Ctrl+C to stop`. The kernel is built to own the process: a signal drains it, then exits it. The platform's background loops never keep a process alive by themselves, so the host decides the lifetime. The run-and-exit kernel boot already exists, and is explicit: `OS_MIGRATE_AND_EXIT=1`. Every in-repo kernel-only boot that is meant to exit sets it, and every other one is stopped by a signal.
12+
- **Unchanged.** A boot with an HTTP server composed, including a `--no-server` boot whose config mounts its own `HonoServerPlugin`. `OS_MIGRATE_AND_EXIT=1`, which still shuts the kernel down and exits 0 before any banner. The IPC messages and the runtime state file.
13+
- **If you relied on the exit.** A script that ran `os serve --no-server` and waited for the process to end now has to set `OS_MIGRATE_AND_EXIT=1` (bootstrap once, then exit 0), or stop the process with a signal.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(service-automation): a resume refused because the run's flow or parked node is gone now settles the run's `sys_automation_run` row
8+
9+
- **What changed.** A resume refused with `RUN_NOT_FOUND` because the run's flow is no longer registered, or because the node it parked on was edited away, now settles the run in the same call. The paused row is consumed and a `failed` history row records the refusal's own text in `error`. Before, the row kept `status: 'paused'` through every later resume, an uninstall and a cold restart. Run lists and paused-run counts kept showing a run the engine already knew could never continue, and retention never aged the row out, because it prunes terminal statuses only.
10+
- **Unchanged:** the answer. It has the same code and the same text, and a transport still answers 404. A second resume answers `RUN_NOT_FOUND` again (`No suspended run`) and writes nothing. Retention is unchanged: `failed` was already in its scope, and paused rows are still never pruned.
11+
- **When nothing is recorded.** Before the host seals the boot, a missing flow may be one that is not registered yet. `AutomationServicePlugin` re-arms overdue wait timers in `start()`, before flows bound at `kernel:ready` arrive. So before the seal the refusal records nothing, as before. The host seals with `sealNodeTypeVocabulary()`, which the plugin calls at `kernel:bootstrapped`. An embedded host that never calls it keeps the previous behaviour.
12+
- **Concurrency.** The settle consumes the row through the same compare-and-set a resume takes. If a concurrent resume or cancel consumed the row first, the call writes nothing. If the store cannot answer the compare-and-set, the resume answers `STORE_UNAVAILABLE` (503), and a retry settles the run.

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@
604604
"title": "Personal API key: minted show-once on Connect-an-Agent, authenticates as its owner, revoke kills it and restore brings it back — mine-view scoped",
605605
"since": "v16",
606606
"status": "active",
607-
"revision": 1,
607+
"revision": 2,
608608
"priority": "P1",
609609
"surface": "mixed",
610610
"personas": ["a signed-in member (key owner)", "platform admin", "the key itself (machine caller via x-api-key)"],
@@ -620,7 +620,7 @@
620620
"capture the mint response: POST /api/v1/keys returns data.key (the raw secret) exactly once, plus id / name / prefix",
621621
"record the raw secret, close the dialog, then re-open the API Keys list (sys_api_key `mine` view) and confirm the row shows only the prefix — the raw key is never shown again (the stored `key` column is hashed + hidden)",
622622
"call a data route as the machine caller: GET /api/v1/data/showcase_task with header x-api-key: <raw secret> (NOT a session cookie/Bearer) and capture the 200 + that the rows returned are the OWNER's row-level-security view (the key acts as the user)",
623-
"revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 401",
623+
"revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 403 whose body's error.code is API_KEY_REVOKED and which carries no rows (a recognised key its own lifecycle refuses — only an UNKNOWN key gets the generic 401)",
624624
"restore via restore_api_key (PATCH … {revoked:false}); re-call and capture the 200 again — the lifecycle is reversible",
625625
"mine-view scoping: sign in as a DIFFERENT member and confirm the sys_api_key `mine` view (filter user_id={current_user_id}) does NOT list the first user's key; then as admin open Setup → API Keys (the `all_keys` view, nav gated on manage_platform_settings) and confirm every user's key is listed",
626626
"attempt to read the raw secret back via the data API (GET /api/v1/data/sys_api_key/{id}) and confirm the `key` field is absent/hidden — only the prefix is ever returned"
@@ -639,10 +639,10 @@
639639
"evidence": "the x-api-key request trace + a session-read comparison"
640640
},
641641
{
642-
"clause": "revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call 401s on the very next request",
642+
"clause": "revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call is refused on the very next request with the key's own lifecycle refusal — 403, error.code API_KEY_REVOKED, and no rows. Since #23367 (executed by #23541) a recognised but revoked key is refused 403 API_KEY_REVOKED; the generic 401 answers only a key the server does not recognise at all. The refusal's message is the key's own lifecycle message and its wording is NOT pinned here: the code is what a client branches on, and the lifecycle owner rewords that text (#23944)",
643643
"oracle": "api",
644-
"verify": "the post-revoke x-api-key request returns 401",
645-
"evidence": "the revoke PATCH response + the 401"
644+
"verify": "the post-revoke x-api-key request returns HTTP 403 and its body's error.code is API_KEY_REVOKED, with no record rows in the body (packages/core/src/security/api-key.ts#lifecycleRefusalOf decides it; the data routes answer it through packages/rest/src/rest-server.ts#enforceAuth). Score the status, the code and the absence of rows, never the message's wording. A 200 or any served row, a generic 401, or a 403 carrying any other code is a FAIL — the last means the key was accepted and something later refused",
645+
"evidence": "the revoke PATCH response + the 403 API_KEY_REVOKED refusal body"
646646
},
647647
{
648648
"clause": "restore is reversible: restore_api_key flips revoked back to false and the key authenticates again (200)",
@@ -665,7 +665,7 @@
665665
],
666666
"negative": [
667667
"the raw secret being readable a second time (in a list row, a get-by-id, or a re-opened dialog) is a FAIL — show-once means the hash is all the server keeps",
668-
"a revoked key still returning 200 on a data route is a FAIL — revocation must be enforced at auth, not only hidden in the list",
668+
"a revoked key still returning 200 on a data route is a FAIL — revocation must be enforced at auth, not only hidden in the list; so is a post-revoke refusal that is the generic 401 or carries any code other than API_KEY_REVOKED (#23367: a client branches on the code, never on the prose)",
669669
"an api key that reads MORE than its owner's RLS view (a superuser bypass) is a FAIL — the key carries the user's permissions",
670670
"one user's key appearing in another user's mine view is an RLS FAIL"
671671
],
@@ -674,10 +674,12 @@
674674
"objectui apps/console/src/pages/developer/AgentConnectSection.tsx (ADR-0036 Phase 2b: POST /api/v1/keys mint, show-once dialog, x-api-key connect steps)",
675675
"packages/runtime/src/route-ledger.ts (POST /keys → client keys.create)",
676676
"packages/platform-objects/src/identity/sys-api-key.object.ts#bodyExtra (revoke_api_key/restore_api_key actions PATCH /api/v1/data/sys_api_key/{id} bodyExtra revoked; mine view user_id={current_user_id} vs all_keys; hashed hidden `key`, visible `prefix`; apiMethods ['get','list'])",
677-
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_api_keys (nav_api_keys requiredPermissions ['manage_platform_settings'] — admin all-view)"
677+
"packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_api_keys (nav_api_keys requiredPermissions ['manage_platform_settings'] — admin all-view)",
678+
"packages/core/src/security/api-key.ts#lifecycleRefusalOf (#23367 / #23541 — a recognised revoked key is refused 403 API_KEY_REVOKED with a located message; an unknown key keeps the generic 401) + packages/rest/src/rest-server.ts#enforceAuth (the data routes answer the remembered lifecycle refusal's status and code)"
678679
],
679680
"history": [
680-
{ "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }
681+
{ "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" },
682+
{ "revision": 2, "date": "2026-10-16", "change": "stale oracle re-pointed (#23992): step 4 and acceptance[2] expected the post-revoke x-api-key call to answer 401, but since the API-key lifecycle (#23367 ruling A, executed by #23541) a recognised revoked key is refused 403 with error.code API_KEY_REVOKED (packages/core/src/security/api-key.ts#lifecycleRefusalOf) and only an unknown key gets the generic 401, so the item scored a correct runtime red. The step and the clause now capture the 403, the code and the absence of rows, and pin none of the refusal's wording (#23944 rewords it). Not loosened: a 200, a generic 401 and a 403 with any other code each still FAIL (the negative names both directions). Step 5 (restore → 200) is unchanged; source gains the refusal's decision and relay sites", "ref": "#23992" }
681683
]
682684
},
683685
{

‎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)