Skip to content

Commit c857a93

Browse files
feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as system, in both directions (ADR-0138 D2b) (#23411)
Fixes #23225 Clause-②: yes (narrowing) ADR-0138 card E2, D2b: the card's last leg (slices 1 and 2, D2, landed as `01d503432b` and `f24574ae0`). Publish refuses an `authRequired: false` endpoint of `type: 'flow'` whose target flow declares `runAs: 'system'`, in both directions, each refusal naming both items and carrying the record's prescription, with one `registered` ADR-0087 semantic entry. **The `Clause-②` line above is the claim's, copied verbatim as the order requires. The measured reading is `yes (narrowing)`, and the changeset says so.** The judge has to be callable from two packages outside `@objectstack/spec` (the metadata layer's package publish and the metadata protocol's flow publish), so it is exported from `@objectstack/spec/api`: five functions and three interfaces join the published surface (`api-surface/api.json` +8). Every landed narrowing that also added exports declares `yes (narrowing)` (for example the `23015`, `23072` and `23165` changesets). No new error code is minted. All three packages are graded `minor`, so either value passes `check-changeset-no-major`. The seat may amend the line. ## Doors, measured on `origin/main` `6eb6f7d65` (the order's H1 and H2) | item | publish door | what it holds | D2b leg | |:--|:--|:--|:--| | endpoint (`api`, code-only: `allowRuntimeCreate: false`) | the stack parse, `ObjectStackDefinitionSchema` (`defineStack`, `os validate`, `os build`, the artifact load) | the stack's `apis` and `flows` together | both directions as one input, in `applyApiEndpointGates` | | endpoint and flow | `MetadataManager.publishPackage` (`POST /api/v1/packages/:id/publish`), `@objectstack/metadata` | the package's `api` and `flow` items, and the manager's index | both directions | | flow (`allowRuntimeCreate: true`) | the metadata protocol: `saveMetaItem` in publish mode, `promoteDraftForPublish` (the per-item publish and `publishPackageDrafts`) | the flow being published | the flow direction: the declared endpoints are read from the engine registry and the `metadata` service | - **H1 partly falsified.** `endpoint-publish-gate.ts`'s entry is `validateApiEndpointDeclarations` (`:155`) with `policyGate` at `:499`, as stated. But other packages do import the gate: `@objectstack/metadata` calls `validateApiEndpointDeclarations` in `gateApiItemsForPublish` (the package publish) and `identityFreeEndpointGateFailure` in the endpoint matcher's load-time backstop. The package publish is the second endpoint publish door, so D2b's endpoint direction lands there too. The order's clause for a measured producer elsewhere covers it; `packages/metadata` was not on the claim's file surface. - **H2 falsified for `@objectstack/service-automation`.** It judges no publish. It registers credential and mutation hooks on the protocol and arms flows after they are published (`registerFlow`, the mutation sync). A stored flow's publish is judged in `metadata-protocol` (`saveMetaItem`, `promoteDraftForPublish`). So this PR changes nothing in service-automation, and its suite is run as a consumer reading. - **H3 holds.** Existing envelopes only: at the parse, a zod `custom` issue at `apis[i].authRequired` (`defineStack` throws `STACK_SCHEMA_INVALID` / 422); at the package publish, the existing `validationErrors` result (`success: false`); at the protocol, `INVALID_METADATA` / 422 with `issues[0].path: 'runAs'`, the envelope of that door's other publish-time contract refusals. - **H4: the `runAs` description misled.** It prescribed `system` for user-less triggers, `api` included, which is the path the record names. It now names the exception (an anonymous flow endpoint) and what serves that caller. This is a text change; `content/docs/references/**` is regenerated. ## What the refusal reads One pure judge, `anonymousElevatedFlowPairs` in `endpoint-publish-gate.ts`'s new section (f), beside `policyGate`. It is not part of the per-endpoint `firstFailure` chain, because no single declaration carries both halves. It pairs an endpoint with `type: 'flow'`, `authRequired: false` (exactly; an absent key is the schema default, `true`) and a non-empty `target` with a flow of that name declaring `runAs: 'system'`. Two refusal sentences word it per direction: `anonymousEndpointElevatedFlowMessage` when the endpoint is being published, `elevatedFlowAnonymousEndpointsMessage` when the flow is. Both carry the record's prescription: an authenticated endpoint for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041) for a partner webhook, or a public form for anonymous intake. `runAs: 'automation'` is not prescribed until ADR-0073 M2 adds the value. Not judged, on purpose: a draft save (a draft does not execute; its publish is judged); a `migrate-stored` re-save of an existing row (refusing it would leave a credential the move takes out of the row in cleartext); and, by the record's own words, the indirect path where a guest-written row fires a record-change flow. Where it composes with #23375 (`eeb95f7ff`, merged into this branch): the D2b leg runs inside `applyApiEndpointGates`, before the per-endpoint gates. The new `.superRefine(applyPermissionSetOwnerBindingGate)` is chained after it, unchanged and unreordered. The protocol edits are in the save and promotion blocks beside the default-record-page refusal. `permissionSetOwnerBindingRefusal`, `assertLockAllowsDelete` and `restoreArtifactRegistryView` are untouched. #23375 covers none of D2b. ## Tests (at `1bd1ba175`, the merged tree) - **Spec**, `src/api/anonymous-elevated-flow-gate.test.ts`, 14 cases: - each direction is the edit that completes the pair from an accepted stack, asserted as `code: 'custom'` at `['apis', 0, 'authRequired']`; - `defineStack` throws `STACK_SCHEMA_INVALID` / 422; - the controls: an authenticated endpoint on a `system` flow, and an anonymous endpoint on a `user` flow or a default-`runAs` flow; - the judge's edges and the prescription in both sentences. - **Metadata**, `src/publish-anonymous-elevated-flow-gate.test.ts`, 8 cases: - the endpoint direction with the flow in the package and in another package, and the flow direction, each refused at its own item with nothing published; - `validate: false` does not disable it; - a publish-envelope body is peeled first; - two controls. - **Metadata protocol**, section 5 of `src/protocol.item-name-every-door.test.ts`, on that file's pinned double (no new engine double, so no ledger change), 10 cases: - the publish-mode save, the per-item publish and `publishPackageDrafts`, each with its control; - the endpoint read from the `metadata` service as well as the registry; - a failing `metadata` service read fails the publish; - draft and `migrate-stored` saves are not judged. - **Consumer readings** at the merged head (each package's own exit 0, run through the shared lock): - `@objectstack/spec` `pnpm test`: 674 files / 20475 tests; - `@objectstack/metadata`: 62 / 888; - `@objectstack/metadata-protocol`: 235 files passed (3 skipped) / 28373 tests; - `@objectstack/service-automation`: 186 / 2486; - `@objectstack/lint`: 138 / 6699; - `@objectstack/cli` `--project unit`: 287 / 4307. The `integration` layer is declared to CI. - **Typecheck**: `spec`, `metadata` and `metadata-protocol` exit 0. Spec's test layer holds its pinned debt, so the new test file adds no error. - **Ablations**: four, through `scripts/ablation-replace.mjs` with its restore trap, run at the pre-merge head `f66d8c10c`, where the merge touched none of the four sites. The expected direction (red) was declared before each run. No build leg: each suite reads the mutated file from `src`. - A1, the stack leg fed no flows: 5 failed / 9 passed, exactly the five stack-parse refusals. - A2, the save-door call removed: 3 failed / 36 passed, the three save-door refusals. - A3, the promotion call removed: 2 failed / 37 passed, the per-item and batch publish refusals. - A4, the package-publish call removed: 5 failed / 3 passed, the five package refusals. - Every anchor went 1 to 0, and every restore was proven: blob equals `HEAD` and `git diff HEAD` is empty. ## Gates (at `1bd1ba175`) - **Derived** with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths): 125 commands at `1bd1ba175`, the same list as at the pre-merge head. - **At the moment this PR opened**, 71 had run: - 70 exit 0; - `check:skill-examples` answered PREREQUISITE NOT MET (exit 3: `@objectstack/client-react` has no built declarations). It is NOT MEASURED until it re-runs after that build. - **The rest is reported on #23225**, in the `os-dev-report` comment, not here (this body is written once): the remaining commands, that re-run, and the `--ran` reconciliation. - **Changeset level axis**: `node scripts/check-changeset-no-major.mjs --base origin/main --event` over this PR's title and body exits 0 ("LEVEL AXIS: … declares clause-② `no (narrowing)`"). The same run with the line amended to `yes (narrowing)` also exits 0. - **Governed paths**: `check-governed-merges --test` over the 16 paths: 0 hit the register, NOT governed. Lint, as a proven narrowing: `eslint --no-inline-config --format json` over the 10 changed `.ts` files reports 10 files, 0 errors, 0 warnings and 0 ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so a change here cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Consumer sweep - `examples/**`: zero `authRequired: false` declarations (one comment mentions the key). - In-tree fixtures: every anonymous flow endpoint in a test names a `runAs: 'user'` flow (`anonymous-door-sweep`, `declarative-endpoint-anonymous-guest`, the runtime endpoint fallback tests). - `guest-owner-stamp.dogfood.test.ts` pairs anonymous `object_operation` endpoints with a `system` record-change flow. That is the record's indirect path, not D2b's pair, and it is untouched. - `showcase_inbound_task_webhook` takes the signed inbound hook with `runAs: 'system'` and is untouched. - No hit needed a fate. ## Acceptance notes - **Restore doors are not judged.** `rollbackMetaItem` and `revertCommit` re-activate a stored flow version through the repository's synchronous `deriveRestoredBody`, which has no async read to ask the endpoints. The record says "the publish path", and no cross-item refusal sits on the restore doors today (the default-record-page refusal does not either). Carrier: none; noted, not filed. - **Not publish doors, so not judged here.** The endpoint matcher's load-time index (`buildEndpointIndex`) holds no flows. A flow registered straight into the engine (`registerFlow`) passes no publish. A pair whose endpoint and flow live in two packages of one composed artifact is judged at the package publish, but not at the stack parse, which reads only top-level `apis` and `flows`, the same scope as the per-endpoint gates. - **No full runtime counterpart.** The executor's `refusesElevatedSelfTriggeredStart` covers self-triggered types only. The record places D2b at publish, and the module header now says so. - **The protocol reads endpoints as its own read API does.** It reads the registry's and the `metadata` service's `api` items as bodies, as `getMetaItems` does. A wrapped publish envelope registered by a direct `metadata.register()` is read only by the package publish, which peels. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5b1bf7 commit c857a93

16 files changed

Lines changed: 1218 additions & 14 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b)
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: registered api-endpoint-anonymous-elevated-flow-refused -->
12+
13+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription.
16+
17+
**What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`:
18+
19+
- **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue.
20+
- **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off.
21+
- **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists.
22+
23+
**Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door.
24+
25+
## FROM → TO
26+
27+
| door | FROM | TO |
28+
|:--|:--|:--|
29+
| `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription |
30+
| package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item |
31+
| metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written |
32+
33+
**The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value.
34+
35+
No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18).
36+
37+
**`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller.
38+
39+
**`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from.
40+
41+
**`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's.
42+
43+
**Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched.

0 commit comments

Comments
 (0)