Skip to content

Commit 3f45b6c

Browse files
fix(service-automation,metadata-protocol,runtime): withhold the remaining stored flow-credential positions at every depth, and answer a /meta list fault as itself (#20590) (#20615)
Part of #20590 Clause-②: no ## What this changes This PR closes the stored-credential positions that the first instance's projection (PR #20585) did not reach. Each position was measured first. Its pin was committed red on the unfixed code (`99a75023`) and then closed (`1f17293b`). The defect is stated abstractly here, per the security-family disclosure rule. - **Position 1: a second credential kind in a flow node's `config`.** The registered `flow` projection (`service-automation/src/flow-credential-projection.ts`) withheld only the start node's hook secret. - It now withholds every position in one table keyed by node kind, `FLOW_NODE_CREDENTIAL_KEYS`: the start node's `secret` and the `http` node's `signingSecret`. - It walks every ADR-0031 region (a `loop` body, `parallel` branches, `try_catch` try and catch) through `FLOW_REGION_SLOTS_BY_TYPE`. A credential-holding node nested at any depth is therefore covered. - **Round trip:** the rule is unchanged. The withheld form (the key absent) keeps the stored value, and an explicit value replaces it. - **Removal door, with no spec change:** the empty string. - `HttpConfigSchema` already accepts it, and it holds no secret. - The messaging outbox signs only with a non-empty secret. - The projection serves it as written, so it round-trips as "cleared". Absent is never read as "remove". - **Enumeration pin:** `flow-credential-positions.test.ts`. - It reads every declared node config contract: each builtin executor's descriptor `configSchema`, the schemaless builtins' spec Zod contracts, and the approval node's contract. - It requires the table to equal the credential-named keys it finds, and each to be withheld at the top level and in every region kind. - A newly declared credential key turns it red until the key is covered, or reviewed out with a reason. It is a test in the same package, not a gate. - **Position 2: a list fallback that served stored bodies on a protocol fault.** The runtime dispatcher's `/meta` list branch (`runtime/src/domains/meta.ts`) no longer swallows a throw from the protocol's list read. The throw is answered as itself (`errorFromThrown`). That is one option, per triage's call. - **Position 3: identity versus kind.** `carryForwardRedactedValues` (`metadata-protocol/src/metadata-redaction.ts`) now does two more things. - It re-runs the type's redactor over what it grafted, and drops any carried value whose new position the read would serve. This is the remedy the at-tier review named. - It walks an array element that has no `id` by the identified node beneath it on the same path. A `parallel` branch has no `id`. Position 1 needs this: once a secret inside a branch is withheld, the old "skip the path" would have deleted it silently on every round trip, including one that reorders the branches. ## The dispatch's mechanism assumptions, measured - **A1 held. Position 1 is REACHED at a member-level read.** - At `c96beb27` (the unfixed code), on a composed in-process boot, the registered projection passed `signingSecret` through at every depth. - The boot was `@objectstack/verify`'s `bootStack` on `examples/app-crm` with the automation capability loaded, one member signed up, and the flow authored by the seeded admin. - The member's item, list and published reads each served both values: the top-level `http` node's and the one inside a `loop` body. - The start node's secret was withheld there, which confirms the #20552 projection was live in that boot. - After this change, none of the three reads carries either value. - **A2 partly falsified: there is no "unknown type" error to discriminate on.** - `ObjectStackProtocolImplementation.getMetaItems` is the one implementation in this repository. It answers a type it holds nothing for with an empty list, because it merges the metadata service's runtime-registered items itself. - What it throws is a store fault (503), a metadata app's marked refusal, a redactor failing closed, or a refused spelling (400). So every throw now propagates, which is what "a fault propagates, an unknown type still falls through" reduces to. - The metadata-service fallback stays for a protocol slot with no list verb. - **Position 2 is REACHED only under a forced fault, and only on a dispatcher-routed host.** A member read on the real `HttpDispatcher` with a forced protocol fault served a flow's hook secret and a datasource's password, `200`. - On the composed boot above, the `/meta` list is `RestServer`'s route. It has no fallback, and a forced fault there answered `503` with nothing served. - **A3 held. Position 3 is REACHED at a member-level read after an authoring round trip.** - On the same composed boot at `c96beb27`, an author's ordinary save kept a node's `id` and changed its kind. The member's next item and list reads then served the old hook secret on that node, and the row at rest held it there. - After this change the carried value is dropped. The node's config at rest is empty, and nothing is served. - **A4: none, as specified.** Details are under Acceptance notes. No in-repo composition serves `/meta` without the automation capability *while sharing a store with one that loads it*. The plugin-absent half does exist in the repository, measured below. ## Tests The pins below were all committed red first, at `99a75023`. - `service-automation`, `flow-credential-positions.test.ts` (new): the enumeration, every position at each depth, exact paths, the cleared form, and a lookalike key on another kind. - `metadata-protocol`, `protocol.metadata-redaction.test.ts`: - relocation through the pure inverse and through the save door (followed by the served reads); - nested carry-forward, including reordered branches and twin branches; - the removal door: the empty string clears, absent keeps, and a value replaces; - the save-door round trip for nested secrets. - `runtime`: - `meta-list-protocol-fault.test.ts` (new): 503, 400 and an undeclared throw are each answered as themselves, with the fallback never called. It also preserves the empty-list answer and the no-list-verb host. - `automation-flow-credential-projection.test.ts`: the relocating `PUT`, then the member's read. - `meta-list-read-gate-parity.test.ts`: its double now reaches the fallback exits by answering no list instead of throwing. Run at `fee1d6b9b`: - Package suites: - `service-automation`: 152 files, 1866 passed. - `metadata-protocol`: 2775 passed, 19 skipped. - `runtime` (`--project local`): 4197 passed, 1 skipped. - `typecheck` is green on all three. `check:test-typecheck` is green on `service-automation` and `runtime`. `metadata-protocol`'s `tsconfig` includes its tests; `--listFiles` counts the edited test once. - Gates: `dispatch-gates.mjs --commands` derives 64 commands from this diff, and all 64 exit 0. `--ran` reconciles 64 derived, 64 run, 0 NOT-MEASURED and 0 UNRUN, every line carrying its exit code. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET`, because 8 unrelated packages had no `dist/`. After building them it exited 0. - Lint: `eslint --no-inline-config` over the 8 changed `.ts` files reports 0 errors and 0 warnings. - The config lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` outside its never-linted directories. - It enables no type-aware rule (`eslint.config.mjs`, "never enables type-aware linting"), so the diff cannot move an untouched file's verdict. ### Ablations Each leg ran on the committed fix and went through `scripts/ablation-replace.mjs`: the anchor hit once, the blob changed, and the restore was proven as "blob == HEAD and `git diff HEAD` empty". The outer shell also carried a restore trap. | # | Put back | Pins that went red | |---|---|---| | A1 | the `http` row of the table | 8 of 15: the table no longer equals the declared set (`expected [ 'start.secret' ] to deeply equal [ Array(2) ]`), and each `signingSecret` placement (`… not to contain 'credential-position-sentinel-20590'`) | | A2 | the region walk | 9 of 15: every nested placement, for both kinds | | A3 | serving the cleared form as written | 1 of 15 | | A4 | the list branch swallowing the protocol throw | 4 of 6: `expected '{"success":true,"data":{"type":"flow"…' not to contain 'stored-hook-secret-20590'`, and the datasource password | | A5 | keeping every graft (no position check) | 3 of 33 in `metadata-protocol`: `expected [ 'inbound_hook', …(17) ] to not include 'stored-hook-secret-20552'`. In `runtime`, which reads `metadata-protocol` from `dist/`, the PUT pin went red once the mutation was rebuilt and `ablation-dist-preflight` found the marker in 2 built files. On restore, the package was rebuilt, the marker was absent from all 24 built files, the tree was clean, and 8 of 8 passed. | | A6 | skipping an id-less element (no anchor) | 3 of 33: `expected undefined to be 'stored-signing-secret-20590'` | A first run of the `dist` leg of A5 used a mutation that failed the package's DTS step (unused locals). Its JS bundles carried the mutation, but its preflight never ran. It was rerun with a mutation that type-checks, and the numbers above are from that rerun. ## Deviations - **Three test files outside the claim's named file surface**, each a test of a door this PR changes: - `runtime/src/domains/automation-flow-credential-projection.test.ts`, the automation-plane half of position 3; - `runtime/src/domains/meta-list-read-gate-parity.test.ts`, whose double modelled "unknown type" as a throw; - the new `runtime/src/domains/meta-list-protocol-fault.test.ts`. - **No change reaches a published package's `exports`.** The new constants in `flow-credential-projection.ts` are not re-exported from `@objectstack/service-automation`'s entry. `carryForwardRedactedValues` keeps its signature; its behaviour changes as described. `Clause-②: no` stands as claimed. ## Acceptance notes - **Position 4 measurement: none, as specified.** Nothing in this repository composes a `/meta`-serving host without the automation capability *over a store shared with one that loads it*. - Searched: the `requires` of every example and dogfood fixture, where every stack declaring flows requires `automation`. - Searched: `os serve`'s always-on slate, where `automation` is not on it and loads only by `requires`, and its presets. - Searched: the CLI commands that boot their own kernel. `os verify` boots in memory, and `os meta` goes through HTTP. - Searched: the one store-sharing seam, `bootStack`'s `databaseFile`, where all 4 in-repo uses pass `automation: true`. - **The plugin-absent half does exist.** `@objectstack/verify`'s `bootStack` loads the automation capability only when `automation: true` is passed (default `false`), whatever the stack's `requires` says. `os verify` boots it that way. - On `bootStack(showcase)` without the flag, the `flow` redactor was absent from the registry, `/automation/*` answered 501, and a member's `/meta/flow` read served an authored `api` flow's start-node secret. - Its store is private (in memory, seeded from the app's own source), so no secret live elsewhere is served there. Recorded for the seat, which owns the position-4 route. - **A second open-map position, same family, not covered here.** - `HttpConfigSchema.headers` is an open string map, and so is `connectorConfig.input`. A static credential typed into one is served with the definition, because it is marked by a header or parameter name, not by a declared key. - The enumeration pin covers declared keys only, and no shipped example authors one. - Named in the report for the seat. It is not filed here, per the family's fold rule. - **Remaining fall-throughs on the dispatcher list.** A protocol slot with no list verb, or a protocol answering no list, still reaches the metadata service's list without per-type redaction. The one in-repo protocol always answers a list, so this is dormant. - **Observations, not filed (no credential served):** - The dispatcher's item read swallows a protocol fault, then falls to a `getItem` that no in-repo metadata service implements, so a fault there reads as 404. - The dispatcher's `/published` read swallows a layered-read fault and serves the code-layer snapshot, which #20585 made redacting. - **Boundary:** a plugin-registered node kind that declares a credential key is outside the table. The enumeration reads builtin and spec-declared contracts. ## Patch round 1 (the seat's append; the dev writes a body only once) - **R1**, the at-tier record `5886643746`: a node moved across regions no longer loses its credential. - When the stored path to the credential's container does not resolve in the incoming body, `carryForwardRedactedValues` finds the owning element by its `id` across the whole incoming body. It uses that element only on exactly one match, then lets the existing position check decide where the value lands. - The pins were committed red on `fee1d6b9` as `0661a9a0`, with 3 failed. The fix and the changeset sentence are `5116194e`. - **The pins:** a node moved out of a `loop` body, and a node moved into a `parallel` branch, each kept, both directly and through the save door. A node moved and changed in kind is dropped. An `id` duplicated across regions grafts nothing. - **The ablation** removed the by-id fallback. Exactly the 3 keep-pins went red, and the restore was proven. - The earlier test titled as a cross-region move is retitled to what it asserts. - A datasource path holds no identified element, so it is unaffected. - **Also in this round:** two stored paths that land on one incoming position carry neither, instead of one silently overwriting the other. With a flow's single id space this cannot be reached, and it errs on the side of not carrying. - **R2** (measured only): the inline `http` arm, and the durable arm's no-outbox fallback, send the request unsigned. Filed as #20628. Not changed here. - **At `5116194e`:** `metadata-protocol` passed 2780, with 19 skipped. `runtime`'s pins for these doors passed 14. `typecheck` exit 0. `dispatch-gates --commands` derived 64 commands, all 64 exit 0, and `--ran` reconciles 64 / 64 / 0 / 0. ## Patch round 2 (the seat's append) - **N1**, the at-tier record `5888558573`: the relocation's match set is scoped to where the owner stood. - It counts only elements of arrays held under the same key as the owner's own array in the stored path. That key is read from the stored hops and never named in code. For a flow it is `nodes`, at the top level or in any region. - An edge, or a config value that carries the same `id`, no longer blocks the relocation. Uniqueness is still required within the scoped set. - An owner whose array sits directly inside another array has no key and is not relocated. No registered redactor produces such a path. - The pins were committed red on `5116194e` as `2b7e04d3`: the edge-twin case, directly and through the save door, 2 failed. The fix is `60a5f765`. - **Ablation:** putting back the whole-body match turned exactly the 2 edge-twin pins red. The restore was proven. - **Changeset:** the "Moving a node" sentence now states the condition the code enforces. - **At `60a5f765`:** `metadata-protocol` passed 2783, with 19 skipped. `runtime`'s pins for these doors passed 14. `typecheck` exit 0. After a full build, `dispatch-gates --commands` derived 64 commands, all 64 exit 0, and `--ran` reconciles 64 / 64 / 0 / 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a918fe7 commit 3f45b6c

9 files changed

Lines changed: 1356 additions & 113 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(security): every credential a flow definition holds is withheld from what is served, at every depth, and an edit round trip keeps each one where it belongs (#20590)
8+
9+
Clause-②: no
10+
11+
**What is now withheld.** Beside an `api` flow's inbound-hook secret (the start node's
12+
`config.secret`), every served flow definition now also withholds an `http` node's
13+
outbound signing secret (`config.signingSecret`), and both are withheld wherever the
14+
node sits: at the top level, or inside a `loop` body, a `parallel` branch, or a
15+
`try_catch` region. The engine still executes the stored values.
16+
17+
**Removing a signing secret.** A definition saved back without the key keeps the
18+
stored secret, because an absent key is what every read serves. To remove it, save
19+
the key as the empty string (`signingSecret: ''`): the durable callout is then
20+
delivered unsigned, and the empty value is served as written, so the next round trip
21+
keeps it cleared.
22+
23+
**Changing a node's kind.** An edit that keeps a node's `id` and changes its kind no
24+
longer carries that node's stored credential onto it. The credential belonged to the
25+
old kind; a start node that needs a secret asks for one again at registration.
26+
27+
**Moving a node.** A node moved into or out of a `loop` body, a `parallel` branch or a
28+
`try_catch` region keeps its stored credential across the round trip, as long as its
29+
`id` and kind are unchanged and it is the only node, at the top level or in any region,
30+
that carries that `id`. An edge or a config value with the same `id` does not count.
31+
32+
**The `/meta` list read on a dispatcher host.** When the metadata protocol's list read
33+
fails, the list answers that failure (`503 SERVICE_UNAVAILABLE` for a store outage, or
34+
the protocol's own refusal) instead of serving the metadata service's stored list,
35+
which applies no credential redaction. A host whose protocol has no list verb keeps
36+
its metadata-service fallback.

‎packages/metadata-protocol/src/metadata-redaction.ts‎

Lines changed: 232 additions & 53 deletions
Large diffs are not rendered by default.

‎packages/metadata-protocol/src/protocol.metadata-redaction.test.ts‎

Lines changed: 449 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/runtime/src/domains/automation-flow-credential-projection.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,29 @@ describe('#20552 — anti-vacuity: the door serves exactly what the registry ent
164164
expect(JSON.stringify(result.response?.body)).toContain(SECRET);
165165
});
166166
});
167+
168+
describe('#20590 — a relocating PUT never lands the secret where the next read serves it', () => {
169+
it('the start node’s kind changed and a new start node added: the member’s next read carries no credential', async () => {
170+
const { dispatcher, spies } = makeDispatcher();
171+
const served = dataOf(await dispatcher.handleAutomation('/inbound_hook', 'GET', undefined, MEMBER));
172+
const [finish, begin] = served.nodes;
173+
const relocated = {
174+
...served,
175+
nodes: [
176+
finish,
177+
{ ...begin, type: 'assignment', label: 'Was the start node', config: {} },
178+
{ id: 'begin_v2', type: 'start', label: 'On Webhook', config: { triggerType: 'api', hookId: 'intake' } },
179+
],
180+
edges: [{ id: 'e1', source: 'begin_v2', target: 'finish' }],
181+
};
182+
183+
const put = await dispatcher.handleAutomation('/inbound_hook', 'PUT', relocated, AUTHOR);
184+
expect(put.response?.status).toBe(200);
185+
const next = await dispatcher.handleAutomation('/inbound_hook', 'GET', undefined, MEMBER);
186+
expect(next.response?.status).toBe(200);
187+
expect(dataOf(next).nodes.map((n: any) => n.id)).toEqual(['finish', 'begin', 'begin_v2']);
188+
expect(JSON.stringify(next.response?.body)).not.toContain(SECRET);
189+
// …and nothing was grafted into what the engine was handed.
190+
expect(JSON.stringify(spies.registerFlow.mock.calls.at(-1)![1])).not.toContain(SECRET);
191+
});
192+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20590 position 2 — the dispatcher's `/meta/:type` list answers a protocol
5+
* FAULT as that fault. It never falls through to the metadata service's raw
6+
* list, which applies no per-type read-path redaction.
7+
*
8+
* ## What the protocol's throw means, measured
9+
*
10+
* `ObjectStackProtocolImplementation.getMetaItems` (the one implementation in
11+
* this repository) answers a type it holds nothing for with `{ items: [] }`:
12+
* it merges the metadata service's runtime-registered items (agents, tools)
13+
* into its own answer, so no type is "unknown" to it in a way it signals by
14+
* throwing. What it throws is a fault or a refusal: the store read failed
15+
* (`503 SERVICE_UNAVAILABLE`, or a metadata app's marked refusal), a
16+
* registered redactor threw (fail-closed by design, `redactMetadataItem`), or
17+
* the segment is an unrecognised spelling of a declared type
18+
* (`400 INVALID_REQUEST`). The branch used to swallow all of them and serve
19+
* `metadataService.list(type)` instead — the stored bodies, so a flow's hook
20+
* secret and a datasource's password went out to a member-level caller on
21+
* any protocol fault. `RestServer`'s list route has no such fallback and
22+
* answers the same throw as itself.
23+
*
24+
* The fallback itself stays, for the host shape it exists for: a protocol
25+
* slot with no list verb at all.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import { HttpDispatcher } from '../http-dispatcher.js';
30+
31+
const HOOK_SECRET = 'stored-hook-secret-20590';
32+
const DB_PASSWORD = 'stored-db-password-20590';
33+
34+
const STORED: Record<string, any[]> = {
35+
flow: [{
36+
name: 'inbound_hook',
37+
label: 'Inbound hook',
38+
type: 'api',
39+
nodes: [{ id: 'begin', type: 'start', label: 'Start', config: { triggerType: 'api', secret: HOOK_SECRET } }],
40+
edges: [],
41+
}],
42+
datasource: [{ name: 'warehouse', label: 'Warehouse', driver: 'postgres', config: { host: 'db', password: DB_PASSWORD } }],
43+
agent: [{ name: 'triage_agent', label: 'Triage agent' }],
44+
};
45+
46+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v));
47+
const singular = (type: unknown): string => String(type ?? '').replace(/s$/, '');
48+
49+
/** A member-level caller: authenticated, and nothing else. */
50+
const MEMBER = { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] };
51+
52+
/** The store fault the protocol raises when its `sys_metadata` read fails (`metadataStoreUnavailableError`). */
53+
function storeFault(): Error {
54+
return Object.assign(new Error('The metadata store could not be read, so whether this item exists is unknown.'), {
55+
code: 'SERVICE_UNAVAILABLE',
56+
status: 503,
57+
});
58+
}
59+
60+
function boot(protocol: Record<string, unknown>) {
61+
const metadata = { list: vi.fn(async (type: string) => clone(STORED[singular(type)] ?? [])) };
62+
const services: Record<string, unknown> = {
63+
protocol,
64+
metadata,
65+
security: { resolvePermissionSetNames: async () => [], getMetadataReadableFields: async () => [] },
66+
};
67+
const get = (n: string) => services[n] ?? null;
68+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
69+
const dispatcher = new HttpDispatcher(kernel);
70+
(dispatcher as any).timedResolveExecutionContext = async () => clone(MEMBER);
71+
const list = async (type: string) => {
72+
const res = await dispatcher.dispatch('GET', `/meta/${type}`, undefined, {}, { request: { headers: {} } } as any);
73+
return { status: res.response?.status ?? 0, body: res.response?.body };
74+
};
75+
return { list, metadata };
76+
}
77+
78+
const text = (v: unknown) => JSON.stringify(v ?? null);
79+
80+
describe('#20590 — a protocol fault on the list read is answered as itself', () => {
81+
for (const [type, credential] of [['flow', HOOK_SECRET], ['datasource', DB_PASSWORD]] as const) {
82+
it(`${type}: a store fault answers 503 SERVICE_UNAVAILABLE and never the stored bodies`, async () => {
83+
const protocol = {
84+
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORED) })),
85+
getMetaItems: vi.fn(async () => { throw storeFault(); }),
86+
};
87+
const { list, metadata } = boot(protocol);
88+
const res = await list(type);
89+
expect(text(res.body)).not.toContain(credential);
90+
expect({ status: res.status, code: res.body?.error?.code }).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' });
91+
expect(metadata.list).not.toHaveBeenCalled();
92+
});
93+
}
94+
95+
it('a protocol refusal keeps its own status and code (400 INVALID_REQUEST)', async () => {
96+
const protocol = {
97+
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORED) })),
98+
getMetaItems: vi.fn(async () => {
99+
throw Object.assign(new Error('not a recognised spelling of a declared metadata type'), { code: 'INVALID_REQUEST', status: 400 });
100+
}),
101+
};
102+
const { list, metadata } = boot(protocol);
103+
const res = await list('flow');
104+
expect({ status: res.status, code: res.body?.error?.code }).toEqual({ status: 400, code: 'INVALID_REQUEST' });
105+
expect(text(res.body)).not.toContain(HOOK_SECRET);
106+
expect(metadata.list).not.toHaveBeenCalled();
107+
});
108+
109+
it('an undeclared throw (a redactor failing closed) is a 500, never the unredacted list', async () => {
110+
const protocol = {
111+
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORED) })),
112+
getMetaItems: vi.fn(async () => { throw new Error('redactor for flow threw'); }),
113+
};
114+
const { list, metadata } = boot(protocol);
115+
const res = await list('flow');
116+
expect(res.status).toBe(500);
117+
expect(text(res.body)).not.toContain(HOOK_SECRET);
118+
expect(metadata.list).not.toHaveBeenCalled();
119+
});
120+
});
121+
122+
describe('#20590 — what still reaches the metadata service fallback', () => {
123+
it('a type the protocol holds nothing for is answered with its empty list — the protocol’s own "unknown" answer', async () => {
124+
const protocol = {
125+
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORED) })),
126+
getMetaItems: vi.fn(async () => ({ items: [] })),
127+
};
128+
const { list, metadata } = boot(protocol);
129+
const res = await list('agent');
130+
expect(res.status).toBe(200);
131+
expect(res.body?.data?.items ?? res.body?.data).toEqual([]);
132+
expect(metadata.list).not.toHaveBeenCalled();
133+
});
134+
135+
it('a protocol slot with no list verb still lists the metadata service’s runtime-registered items', async () => {
136+
const protocol = { getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORED) })) };
137+
const { list, metadata } = boot(protocol);
138+
const res = await list('agent');
139+
expect(res.status).toBe(200);
140+
expect(metadata.list).toHaveBeenCalledWith('agent');
141+
expect(text(res.body)).toContain('triage_agent');
142+
});
143+
});

‎packages/runtime/src/domains/meta-list-read-gate-parity.test.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,21 @@ const CALLERS: Record<'holder' | 'non-holder' | 'anonymous', Caller> = {
139139
type CallerName = keyof typeof CALLERS;
140140

141141
/**
142-
* One protocol double, the same shape both transports read. `unknownTypes`
143-
* makes `getMetaItems` throw for those types — the "protocol doesn't know this
144-
* type" answer that sends the dispatcher on to its fallback stores — while it
145-
* still answers every other type, the books the doc audience reads included.
142+
* One protocol double, the same shape both transports read. `unansweredTypes`
143+
* makes `getMetaItems` answer NO list for those types — the one protocol
144+
* answer that sends the dispatcher on to its fallback stores — while it still
145+
* answers every other type, the books the doc audience reads included.
146+
*
147+
* [#20590] Not a throw. A protocol throw is a fault and is answered as itself
148+
* (`meta-list-protocol-fault.test.ts`); it used to be read as "the protocol
149+
* does not know this type", which the one real protocol never signals that
150+
* way — it answers such a type with an empty list.
146151
*/
147-
function protocolDouble(unknownTypes: string[] = []) {
152+
function protocolDouble(unansweredTypes: string[] = []) {
148153
return {
149154
getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })),
150155
getMetaItems: vi.fn(async ({ type }: any) => {
151-
if (unknownTypes.includes(singular(type))) throw new Error(`unknown metadata type '${type}'`);
156+
if (unansweredTypes.includes(singular(type))) return undefined;
152157
return clone(STORE[singular(type)] ?? []);
153158
}),
154159
};

‎packages/runtime/src/domains/meta.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,15 +1874,29 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
18741874
const data = await protocol.getMetaItems({ type: typeOrName, packageId, organizationId, previewDrafts });
18751875
// Return any valid response from protocol (including empty items arrays)
18761876
if (data && (data.items !== undefined || Array.isArray(data))) listed = data;
1877-
} catch {
1878-
// Protocol doesn't know this type, fall through
1877+
} catch (e: any) {
1878+
// [#20590] A throw here is a FAULT, answered as itself — never a
1879+
// cue to serve the metadata service's list below, which holds
1880+
// the stored bodies and applies no per-type read-path redaction
1881+
// (a flow's hook secret, a datasource's password). The protocol
1882+
// answers a type it holds nothing for with an empty list — it
1883+
// merges the metadata service's runtime-registered items (agents,
1884+
// tools) into its own answer — so it never signals "unknown
1885+
// type" by throwing. What it throws is a failed store read
1886+
// (503), a metadata app's marked refusal, a redactor failing
1887+
// closed, or a refused spelling (400). `RestServer`'s list route
1888+
// answers the same throw the same way ("prefer failing to
1889+
// falling back", AGENTS.md).
1890+
return { handled: true, response: deps.errorFromThrown(e, 500) };
18791891
}
18801892
}
18811893
// [ADR-0106 D5(2)] The dispatcher's list read is the same outlet as
18821894
// REST's `GET /meta/object`, reached by a different door.
18831895
if (listed !== undefined) return answerList(listed);
18841896

18851897
// Try MetadataService directly for runtime-registered metadata (agents, tools, etc.)
1898+
// — reached only by a host whose protocol slot has no list verb, or
1899+
// whose protocol answered no list at all; never on a protocol fault.
18861900
const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata);
18871901
if (metadataService && typeof (metadataService as any).list === 'function') {
18881902
let items: any;

0 commit comments

Comments
 (0)