Skip to content

Commit 042ef51

Browse files
fix(showcase): re-land the recalc as a declared flow endpoint that writes as its caller (#23277)
Fixes #23128 Clause-②: no ## What this changes The showcase's two `api` actions, **Recalculate Estimate** (`showcase_recalc_estimate`) and **Recalculate Selection** (`showcase_recalc_selection`), posted to a code-mounted `http.server` route. That route wrote through `ctx.ql.update` with no execution context, so the security layer refused every write as principal-less and both actions answered 500 with nothing written. Round 1 measured this on stock showcase (`6138940364`), and 0 commits touched the route or the actions between that measurement (`dc93781c19`) and this branch's base (`afbf960c26`). Under the maintainer's ruling C′ (`6142279866`, item 1) the recalc is now metadata. There is no handler code: - **Endpoint.** `showcase_recalc_estimates_api` in `src/system/apis/index.ts`: `POST /api/v1/apps/showcase/recalc`, `type: 'flow'`, `authRequired: true`, target `showcase_recalc_estimates`. - **Entry flow.** `showcase_recalc_estimates` (`type: 'screen'`, the showcase's existing precedent for an on-demand entry behind an endpoint, `runAs: 'user'`). An `assignment` turns either request body into a list of ids. A `map` node then runs the per-task flow once per id. - **Per-task flow.** `showcase_recalc_task_estimate` (`type: 'autolaunched'`, `runAs: 'user'`): - `get_record` reads the row's own `start_date`/`end_date`; - an `assignment` computes the window's days × 8 in CEL; - `update_record` writes `estimate_hours`. - **Actions.** Both are retargeted to `/api/v1/apps/showcase/recalc`. - **Deleted.** `src/system/server/recalc-endpoint.ts`, its import and its `kernel:ready` mount in `objectstack.config.ts`, and its unit test `test/recalc-endpoint.test.ts`. - **Re-pointed.** Every showcase reader of the old route (listed below). ⛔ Not a system context. Triage `6137971940` holds, and the ablation below shows what `system` would allow. ## The computation, measured The CEL in the `assignment` (`WINDOW_ESTIMATE` in `src/automation/flows/recalc-estimate.flow.ts`): ``` has(task.start_date) && has(task.end_date) && task.start_date != null && task.end_date != null && daysBetween(task.start_date, task.end_date) >= 0 ? (daysBetween(task.start_date, task.end_date) + 1) * 8 : 8 ``` - **Same as the deleted code.** It reproduces `estimateFromWindow`: inclusive days × 8, and 8 when either date is missing or the end precedes the start. `daysBetween` (formula stdlib) rounds `(b − a) / 86 400 000` exactly as the old `Math.round(...)` did. - **One change: the window comes from the stored row, not the request.** The console sends only `{ recordId }`, so the old route always fell back to 8 on that path. Its bulk branch wrote a flat 8 per id. - **Measured on a real boot** (`bootShowcase`, automation on): - window 2026-06-16..2026-07-02 → `136`; - window 2026-03-02..2026-03-06 → `40`; - no end date → `8`. - The admin single answer was `200 {"success":true,"data":{"success":true,"output":{"estimates":[{"estimate_hours":136}]}, …}}`. - **No `script` node needed.** The ruling's fallback did not arise: `assignment.assignments.*` and `update_record.fields.*` are CEL value slots (`flow-node-expression-paths.ts`, role `value`), and `daysBetween` / `has` / `isBlank` are registered stdlib. ## `runAs` The spec default is `user` (`flow.zod.ts`, `runAs: z.enum(['system','user']).default('user')`). Both flows declare it explicitly anyway. Read back from the booted engine's `getFlow`: - `showcase_recalc_estimates` → `{type:'screen', runAs:'user'}` - `showcase_recalc_task_estimate` → `{type:'autolaunched', runAs:'user'}` The `map` child run spreads the parent context (`map-node.ts`, `childContext = {...context, params}`), so the caller's identity reaches every per-task write. ## How the actions reach the endpoint, and the bulk shape - **The console's executor** (objectui `1fb1ced36243`, read only): `apiHandler` in `packages/app-shell/src/hooks/useConsoleActionRuntime.tsx` takes an absolute `target` and POSTs it through the authenticated fetch. - The body is the action's params plus `recordIdParam`. - A non-2xx answer becomes an error toast. - Per record (`execution: 'perRecord'`): `{ recordId }`. - Aggregate (`bulkActionDefs` with `execution: 'aggregate'`): ONE request with `{ _selectedIds: [...] }`. - **The endpoint side.** It hands the body to `buildAutomationContext` (`packages/runtime/src/domains/automation.ts`). Flat keys become `params`, so `recordId` and `_selectedIds` seed the entry flow's two `isInput` variables (each defaults to `null`). - **The bulk path runs the same flow per entry.** The entry flow's `map` runs `showcase_recalc_task_estimate` once per id, sequential and fail-fast (`map-node.ts`). The single-record body is a list of one. - **The aggregate contract** (`packages/spec/src/ui/view.zod.ts`, `bulkActionDefs`): "Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action." - The first task that cannot be recalculated fails the whole request (`400`), so a partial selection is never reported as success. - The run is not a transaction: tasks before the refused one keep their new value. Re-running is safe because the recalc is idempotent. The flow's doc comment says so. - **A body naming no task.** `{}` and `{ _selectedIds: [] }` both become `[null]`. The per-task `get_record` then refuses a filter operand that evaluates to nothing, so the request answers `400` and does not widen the read. ## The pins (dogfood, real boot) Section 3b of `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts` reuses that file's showcase boot. The refused caller is a signed-up member holding only the `everyone` baseline (`showcase_member_default`: `showcase_task` read + create, no edit). - **Precondition.** The member reads the row (`200`), and the data door refuses its edit with `403 PERMISSION_DENIED`. - **Pin 1.** An admin recalc of one task answers `200` and writes `136`. An unnamed row is untouched. - **Pin 2.** A member recalc of one task is refused and the row keeps its sentinel. - The answer is `400` with `error.code: FLOW_FAILED`, and its message carries the security layer's own refusal. The test compares it with the live data-door answer, not a copied sentence. - Measured body: `{"success":false,"error":{"code":"FLOW_FAILED","message":"Node 'recalc_each' failed: map 'recalc_each': item 0 (subflow 'showcase_recalc_task_estimate') failed: Node 'write_estimate' failed: update_record(showcase_task) failed: You do not have permission to perform this action. Contact your administrator if you need access.","httpStatus":400, …}}`. - **Pin 3, bulk.** - The admin's `{ _selectedIds: [a, b, c] }` answers `200` and writes `136 / 40 / 8`, each task from its own window. - The member's same body is refused the same way, with all three rows unchanged. - **Also pinned.** Anonymous → `401` before the flow runs, nothing written. `{}` → refused. **On "its envelope".** The refusal is the security layer's: the same words, for the same edit, that the data door answers under `403 PERMISSION_DENIED`. A flow endpoint re-envelopes any failed run as the shared flow-dispatch table's `400 FLOW_FAILED` (`packages/runtime/src/flow-dispatch-status.ts`, the same answer as `POST /automation/:name/trigger`). That is because `update_record`'s catch returns the message without a `code`. The pins hold what the platform answers, and this is raised for the PM in the report. ## Readers of the deleted route | reader | disposition | |---|---| | `objectstack.config.ts` | import removed, `onEnable` mount removed, the `apis:` comment re-pointed | | `src/system/apis/index.ts` | "the code-mounted counterpart" wording replaced; endpoint added | | `src/ui/actions/index.ts` | both targets retargeted; comments that named the route's "per-record branch" / "batch branch" re-pointed to the flow | | `src/ui/views/task.view.ts` | comment's old URL and "batch branch" re-pointed | | `src/coverage.ts` | the trailing "remains the code-mounted HTTP counterpart" sentence replaced | | `src/docs/showcase_tour_system.md` | "Code-only surfaces" (it described only the deleted file) replaced by "HTTP endpoints"; frontmatter description updated | | `test/gap-fill.test.ts` | the file-existence pin replaced by a static pin: both actions post to a declared POST flow endpoint, and both recalc flows declare `runAs: 'user'` | | `test/recalc-endpoint.test.ts` | deleted (it unit-tested the deleted file) | | `src/system/translations/index.ts` | read, unchanged: "the two recalc surfaces of the same endpoint" is still accurate | | `src/ui/pages/task-detail.page.ts` | read, unchanged: it names the action ids only | ## Verification at `2ce73b3234` - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on `2ce73b3234` derives 58 commands. All 58 exit 0, with exit codes captured before any pipe. - `--ran` reconciliation: `✓ dispatch-gates --ran: 58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED (a DERIVED zero — all 58 recorded an exit code and none of them is 3)`. - **Showcase tests.** `pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2` → `Test Files 32 passed (32)`, `Tests 408 passed (408)`, `VERDICT command-exit 0`. - **Showcase typecheck.** `pnpm --filter @objectstack/example-showcase run typecheck` → `VERDICT command-exit 0`. - **Validate.** `pnpm --filter @objectstack/example-showcase run validate` → `✓ Validation passed`, `VERDICT command-exit 0`. It prints 70 warnings, none of which names the new flows or the endpoint. - **Dogfood file.** `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/showcase-declarative-endpoints.dogfood.test.ts` → `Tests 24 passed (24)`, `VERDICT command-exit 0`. - **Dogfood typecheck.** `pnpm --filter @objectstack/dogfood run typecheck` → `VERDICT command-exit 0`. `tsc --listFiles` contains both the test file and `recalc-estimate.flow.ts` (1 hit each). - **Lint, by proven narrowing** (repo-wide `pnpm lint` is CI's): - ① The population is read from eslint's own resolved config: every touched lintable file has `isPathIgnored=false` and 4 to 5 active rules. - ② `eslint --no-inline-config --format json` over those 9 files → 9 files, 0 errors, 0 warnings. - ③ Type-aware linting is enabled for no file (no `parserOptions.project` / `projectService` in `eslint.config.mjs`), so this diff cannot move a verdict on an untouched file. - The one Markdown file is outside eslint's population. **Ablations** (each through `scripts/ablation-replace.mjs` with an anchor that hit once, under a restore trap; restoration proven by blob == HEAD `aa606566b4b5` and an empty `git diff HEAD`): - **`runAs: 'user'` → `'system'` on `showcase_recalc_task_estimate`.** - Predicted: the two member cases go red. Observed: exactly those 2 failed, 22 passed. - Under `system` the member's single recalc answered `200` with `acted: 1`, and its selection `200` with `acted: 3`. The member was writing rows it may not edit. - **The inclusive `+ 1` dropped from the assignment.** - Predicted: the two admin cases go red. Observed: exactly those 2 failed (`expected 128 to be 136`), 22 passed. No changeset. Both moved packages are `private: true` (`@objectstack/example-showcase`, `@objectstack/dogfood`), so nothing publishes. The gate that decides is `Check Changeset` (`.github/workflows/pr-automation.yml`), whose route 2 ("It releases nothing … examples/, tests-only") is the `skip-changeset` label. That label is not applied by this PR; see the report. ## Acceptance notes - **Item 2's surface still names the deleted route.** These sites belong to ruling C′ item 2 (the director's), and both the ruling and this claim keep them out of this PR. Each now names a route that no longer exists: - `packages/spec/src/migrations/entries/semantic/18.plugin-manifest-contributes-routes-retired.ts:13`: the `replacement` text an author is shown says "`examples/app-showcase` mounts POST /api/v1/showcase/recalc that way"; - `packages/spec/src/api/plugin-rest-api.zod.ts:36-37` and its generated copy `content/docs/references/api/plugin-rest-api.mdx:37`; - the `packages/spec/liveness/manifest.json:194` note; - the scope note in `scripts/check-optional-error-sink-contract.mjs:164`. It counts the example's one red sink, and that count is now zero. It is a comment only, and the gate scans `packages/**`. - **The flow door drops the security code.** A security refusal inside a flow reaches the caller as `400 FLOW_FAILED`, not `403 PERMISSION_DENIED`, because `update_record`'s catch (`service-automation` `crud-nodes.ts`) returns the message without a `code`. The flow-dispatch table declares `FLOW_FAILED` for every run that ran and failed, so this is the declared contract, not a defect of it. - **A bulk refusal is not a rollback.** A refusal at item k leaves items before k written. That is within the aggregate contract's "must reject" plus its re-run remedy, and this recalc is idempotent. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c18162 commit 042ef51

12 files changed

Lines changed: 445 additions & 236 deletions

File tree

‎examples/app-showcase/objectstack.config.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import * as objects from './src/data/objects/index.js';
1717
import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-external.datasource.js';
1818
import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js';
1919
import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js';
20-
import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js';
2120
import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js';
2221
import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js';
2322
import { ShowcaseApp } from './src/ui/apps/index.js';
@@ -256,8 +255,9 @@ export default defineStack({
256255
// carve-out `/api/v1/apps/<manifest.namespace>/…`, which is why
257256
// `manifest.namespace: 'showcase'` above is load-bearing rather than
258257
// decorative: publish rejects an `apis:` block without it. See
259-
// src/system/apis/ for the declarations and the `authRequired` reasoning;
260-
// src/system/server/recalc-endpoint.ts remains the code-mounted counterpart.
258+
// src/system/apis/ for the declarations and the `authRequired` reasoning.
259+
// They are the app's whole HTTP surface: the recalc behind the two `api`
260+
// actions is one of them, a flow that writes as its caller.
261261
apis: allApis,
262262
// Declarative `connectors:` — both kinds (ADR-0097): provider-bound
263263
// INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via
@@ -293,8 +293,6 @@ export default defineStack({
293293
*/
294294
export const onEnable = async (ctx: unknown): Promise<void> => {
295295
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
296-
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
297-
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[0]);
298296
// Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot:
299297
// assign the dev admin the approver positions and launch the signoff flows so
300298
// real pending requests land in the inbox (cannot be a seed — see

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { defineFlow } from '@objectstack/spec';
44
import { DynamicApprovalFlow } from './dynamic-approval.flow';
55
import { ApproverBindingsFlow } from './approver-bindings.flow';
6+
import { RecalcEstimatesFlow, RecalcTaskEstimateFlow } from './recalc-estimate.flow';
67

78
/**
89
* Task Completed → Notify — an autolaunched, record-triggered flow that fires
@@ -2012,4 +2013,8 @@ export const allFlows = [
20122013
// designer's Value control has a specimen for each. Draft on purpose — see
20132014
// the module docstring.
20142015
ApproverBindingsFlow,
2016+
// The recalc behind the two `api` actions, served by the declared
2017+
// `showcase_recalc_estimates_api` endpoint (src/system/apis/).
2018+
RecalcEstimatesFlow,
2019+
RecalcTaskEstimateFlow,
20152020
];
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineFlow } from '@objectstack/spec';
4+
5+
/**
6+
* The estimate recalculation behind the two `api` actions
7+
* (`showcase_recalc_estimate`, `showcase_recalc_selection`) — declared, with
8+
* no handler code anywhere.
9+
*
10+
* The actions POST to `RecalcEstimatesEndpoint` (`src/system/apis/index.ts`),
11+
* a `type: 'flow'` endpoint, so the request runs through the platform's own
12+
* door: the caller's session is resolved, `authRequired` turns an anonymous
13+
* caller away with `401` before anything runs, and the flows below run AS THE
14+
* CALLER (`runAs: 'user'`). Every read and write they make is judged by the
15+
* security layer as that user's own edit — a caller without edit rights on a
16+
* task is refused there, and nothing is written.
17+
*
18+
* ⛔ Not `runAs: 'system'`. An elevated recalc would write a row its caller
19+
* may not edit: the endpoint would become a way around the caller's own
20+
* permissions, which is the opposite of what an example should teach.
21+
*
22+
* Two flows, because the selection is a list and the recalculation is per row:
23+
*
24+
* - {@link RecalcTaskEstimateFlow} recalculates ONE task: read its schedule
25+
* window, compute the estimate in an `assignment`, write it with
26+
* `update_record`.
27+
* - {@link RecalcEstimatesFlow} is the endpoint's target. It turns either
28+
* request body into a list of ids and runs the per-task flow once per id
29+
* through a `map` node — the single-record body is a list of one.
30+
*/
31+
32+
/**
33+
* The window's days × 8h, in CEL: inclusive whole days from `start_date` to
34+
* `end_date`, eight hours each. A task with no window — either date unset, or
35+
* an end before its start — gets one day (8h).
36+
*
37+
* `has()` comes first because CEL refuses to read a key the record does not
38+
* carry; `daysBetween` takes the two dates as stored (`YYYY-MM-DD`) and counts
39+
* whole days between them, so the `+ 1` is what makes the window inclusive.
40+
*/
41+
const WINDOW_ESTIMATE =
42+
'has(task.start_date) && has(task.end_date) && task.start_date != null && task.end_date != null'
43+
+ ' && daysBetween(task.start_date, task.end_date) >= 0'
44+
+ ' ? (daysBetween(task.start_date, task.end_date) + 1) * 8'
45+
+ ' : 8';
46+
47+
/** Recalculate one task's `estimate_hours` from its own schedule window. */
48+
export const RecalcTaskEstimateFlow = defineFlow({
49+
name: 'showcase_recalc_task_estimate',
50+
label: 'Recalculate One Task Estimate',
51+
description: 'Recomputes one task’s estimate_hours from its schedule window (inclusive days × 8h) and writes it, as the caller.',
52+
// Started only by `RecalcEstimatesFlow`'s `map` node, never as an entry.
53+
type: 'autolaunched',
54+
// The caller's own identity, stated rather than left to the default: this is
55+
// the line that makes the write the caller's edit.
56+
runAs: 'user',
57+
variables: [
58+
{ name: 'recordId', type: 'text', isInput: true },
59+
{ name: 'estimate_hours', type: 'number', isOutput: true },
60+
],
61+
nodes: [
62+
{ id: 'start', type: 'start', label: 'Start' },
63+
{
64+
id: 'load_task',
65+
type: 'get_record',
66+
label: 'Read the schedule window',
67+
// The window is read from the stored row, never from the request: the
68+
// console sends only the id, and a body could carry any dates at all.
69+
config: {
70+
objectName: 'showcase_task',
71+
filter: { id: { dialect: 'cel', source: 'recordId' } },
72+
fields: ['start_date', 'end_date'],
73+
outputVariable: 'task',
74+
},
75+
},
76+
{
77+
id: 'compute_estimate',
78+
type: 'assignment',
79+
label: 'Days in the window × 8h',
80+
config: {
81+
assignments: {
82+
estimate_hours: { dialect: 'cel', source: WINDOW_ESTIMATE },
83+
},
84+
},
85+
},
86+
{
87+
id: 'write_estimate',
88+
type: 'update_record',
89+
label: 'Write the estimate',
90+
config: {
91+
objectName: 'showcase_task',
92+
filter: { id: { dialect: 'cel', source: 'recordId' } },
93+
fields: { estimate_hours: { dialect: 'cel', source: 'estimate_hours' } },
94+
},
95+
},
96+
{ id: 'end', type: 'end', label: 'End' },
97+
],
98+
edges: [
99+
{ id: 'e1', source: 'start', target: 'load_task' },
100+
{ id: 'e2', source: 'load_task', target: 'compute_estimate' },
101+
{ id: 'e3', source: 'compute_estimate', target: 'write_estimate' },
102+
{ id: 'e4', source: 'write_estimate', target: 'end' },
103+
],
104+
});
105+
106+
/**
107+
* The endpoint's target: recalculate the tasks a request names.
108+
*
109+
* The two actions send the two bodies the bulk-dispatch contract defines
110+
* (`execution` on the action, `bulkActionDefs` on the view):
111+
*
112+
* - per record — `{ recordId }`, one request per task;
113+
* - aggregate — `{ _selectedIds: [...] }`, ONE request for the whole
114+
* selection.
115+
*
116+
* Both become one list, and the `map` node runs {@link RecalcTaskEstimateFlow}
117+
* for each id, in order. The `map` stops at the first task it cannot
118+
* recalculate and the whole request is refused, so a caller is never told the
119+
* selection was recalculated when part of it was not — the aggregate contract's
120+
* "a handler that cannot cover the whole selection must reject" (`bulkActionDefs`
121+
* in `@objectstack/spec`'s list view). The run is not a transaction: tasks
122+
* before the refused one keep their new estimate. The contract's remedy is to
123+
* run the action again, and that is safe here because recalculating a task
124+
* twice writes the same value.
125+
*
126+
* A body naming no task at all is refused too: the list is then `[null]`, and
127+
* the per-task read refuses a filter whose id evaluates to nothing.
128+
*
129+
* `type: 'screen'` because it IS an entry — a signed-in caller starts it on
130+
* demand through the endpoint (the `showcase_inquiry_purge` precedent). It has
131+
* no screen, so it runs straight through.
132+
*/
133+
export const RecalcEstimatesFlow = defineFlow({
134+
name: 'showcase_recalc_estimates',
135+
label: 'Recalculate Estimates',
136+
description: 'Recalculates the estimate of every task a request names — one id, or a whole selection — by running the per-task recalculation for each, as the caller.',
137+
type: 'screen',
138+
runAs: 'user',
139+
variables: [
140+
// Each body carries exactly one of the two, so both default to `null`: an
141+
// unbound name cannot even be compared in CEL.
142+
{ name: 'recordId', type: 'text', isInput: true, defaultValue: null },
143+
{ name: '_selectedIds', type: 'list', isInput: true, defaultValue: null },
144+
// Each task's recalculated estimate, in request order.
145+
{ name: 'estimates', type: 'list', isOutput: true },
146+
],
147+
nodes: [
148+
{ id: 'start', type: 'start', label: 'Start' },
149+
{
150+
id: 'collect_ids',
151+
type: 'assignment',
152+
label: 'The tasks this request names',
153+
config: {
154+
assignments: {
155+
task_ids: {
156+
dialect: 'cel',
157+
source: '_selectedIds != null && size(_selectedIds) > 0 ? _selectedIds : [recordId]',
158+
},
159+
},
160+
},
161+
},
162+
{
163+
id: 'recalc_each',
164+
type: 'map',
165+
label: 'Recalculate each task',
166+
config: {
167+
collection: { dialect: 'cel', source: 'task_ids' },
168+
flowName: 'showcase_recalc_task_estimate',
169+
iteratorVariable: 'task_id',
170+
input: { recordId: { dialect: 'cel', source: 'task_id' } },
171+
outputVariable: 'estimates',
172+
},
173+
},
174+
{ id: 'end', type: 'end', label: 'End' },
175+
],
176+
edges: [
177+
{ id: 'e1', source: 'start', target: 'collect_ids' },
178+
{ id: 'e2', source: 'collect_ids', target: 'recalc_each' },
179+
{ id: 'e3', source: 'recalc_each', target: 'end' },
180+
],
181+
});

‎examples/app-showcase/src/coverage.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
130130
status: 'demonstrated',
131131
files: ['src/system/apis/index.ts'],
132132
notes:
133-
'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). src/system/server/recalc-endpoint.ts remains the code-mounted HTTP counterpart.',
133+
'Declarative ApiEndpoint metadata (object_operation + flow targets), MEASURED on a real boot rather than asserted: packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts boots the showcase through the artifact-ingestion path and proves each declared path is matched and executed (the find endpoint answers byte-identically to the built-in /data route for the same operation), that `authRequired` denies anonymous with 401, that `cacheTtlSeconds: 30` reaches the wire as Cache-Control on successes only, and that /openapi.json and GET /meta/api describe exactly what is mounted. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" once BEFORE that was true — #4936 measured it and found a bare 404 on every declared path, which is why the waiver stood from #4936 until the #5040 executor landed. It is restored to `demonstrated` only because a real-boot test now fails if any of it stops being true (#5040 E8 / #5112). The `router` kind stays retired: code-only (ADR-0088). The app mounts no route handler of its own: the recalc behind the two `api` actions is a declared flow endpoint too (`showcase_recalc_estimates_api`), and the same file pins that its flow writes as the caller — a recalc by an admin updates the estimate, a caller without edit rights is refused by the security layer with nothing written, for one task and for a selection alike.',
134134
},
135135
translation: { status: 'demonstrated', files: ['src/system/translations/index.ts'] },
136136
email_template: { status: 'demonstrated', files: ['src/system/emails/index.ts'] },

‎examples/app-showcase/src/docs/showcase_tour_system.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: "Tour · System"
3-
description: Guided tour of the system domain — datasources & federation, i18n, email templates, docs-as-metadata, and the custom endpoint.
3+
description: Guided tour of the system domain — datasources & federation, i18n, email templates, docs-as-metadata, and the declared HTTP endpoints.
44
---
55

66
# Guided tour — System
@@ -33,12 +33,16 @@ reading, exposed publicly through the library portal. The
3333
[documentation guide](./showcase_docs_guide.md) states the authoring rules
3434
this very page obeys.
3535

36-
## Code-only surfaces
36+
## HTTP endpoints
3737

38-
`src/system/server/recalc-endpoint.ts` mounts a custom REST endpoint
39-
imperatively — the code-level counterpart of the `router` / `function` /
40-
`service` kinds, which have no declarative authoring surface today (waived
41-
in the coverage manifest with the tracking issue).
38+
`src/system/apis/` declares every endpoint this app serves, under its own
39+
`/api/v1/apps/showcase/` namespace. None has handler code: each one names
40+
a pipeline the platform already runs, a data operation or a flow. The
41+
**Recalculate Estimate** and **Recalculate Selection** buttons on a task
42+
call `POST /api/v1/apps/showcase/recalc`, whose flow recomputes the
43+
estimate from the task's schedule window and writes it as the person who
44+
clicked. Someone who may not edit the task is refused, and nothing is
45+
written.
4246

4347
Continue with the [Security tour](./showcase_tour_security.md), or go back
4448
to the [overview](./showcase_index.md).

‎examples/app-showcase/src/system/apis/index.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type { ApiEndpoint } from '@objectstack/spec/api';
77
*
88
* ## What these are
99
*
10-
* The metadata-authored counterpart of the code-mounted endpoint in
11-
* `src/system/server/recalc-endpoint.ts`. The runtime matches a declared
10+
* Every HTTP endpoint this app serves, declared as metadata: an app does not
11+
* mount raw route handlers of its own. The runtime matches a declared
1212
* `path` + `method` against the stored `api` items and executes the target
1313
* with NO handler code: `object_operation` delegates to the same `callData`
1414
* the built-in `/data` route uses, `flow` to the same automation pipeline
@@ -48,14 +48,17 @@ import type { ApiEndpoint } from '@objectstack/spec/api';
4848
*
4949
* ## `authRequired` is deliberate here, not incidental
5050
*
51-
* Both endpoints are session-gated. `authRequired` DEFAULTS to `true`, so the
51+
* Every endpoint here is session-gated. `authRequired` DEFAULTS to `true`, so the
5252
* explicit `true` below is documentation rather than protection — but it is
5353
* the key whose historical value matters most: an explicit `false` is the only
5454
* thing that opens an anonymous execution entry point, and ADR-0121 D6 then
5555
* requires it to carry an ARMED `rateLimit` (`enabled: true`; the key defaults
56-
* to `false`, so a budget written without it meters nothing). Neither of these
56+
* to `false`, so a budget written without it meters nothing). None of these
5757
* endpoints was ever anonymous, and restoring them did not make one anonymous:
58-
* an example must not demo an anonymous surface it never had.
58+
* an example must not demo an anonymous surface it never had. The recalc
59+
* endpoint below WRITES, so for it the gate is also the first of two: an
60+
* anonymous caller is turned away with `401` before its flow runs, and a
61+
* signed-in one is then judged row by row as themselves.
5962
*
6063
* Proven end to end, on a real boot, by
6164
* `packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts`.
@@ -114,4 +117,25 @@ export const InquiryPurgeEndpoint: ApiEndpoint = {
114117
authRequired: true,
115118
};
116119

117-
export const allApis: ApiEndpoint[] = [TaskFeedEndpoint, InquiryPurgeEndpoint];
120+
/**
121+
* Flow-typed endpoint behind the two `api` actions — `showcase_recalc_estimate`
122+
* (one task) and `showcase_recalc_selection` (a whole selection in one call).
123+
*
124+
* Its target runs `runAs: 'user'`, so the recalculation writes as the caller:
125+
* the security layer judges each task's write as that user's own edit, and a
126+
* caller without edit rights is refused there with nothing written. The flow
127+
* reads both request bodies the actions send, `{ recordId }` and
128+
* `{ _selectedIds }` (`src/automation/flows/recalc-estimate.flow.ts`).
129+
*/
130+
export const RecalcEstimatesEndpoint: ApiEndpoint = {
131+
name: 'showcase_recalc_estimates_api',
132+
path: '/api/v1/apps/showcase/recalc',
133+
method: 'POST',
134+
summary: 'Recalculate task estimates',
135+
description: 'Recomputes estimate_hours from each named task’s schedule window (inclusive days × 8h), as the caller. Body: { recordId } for one task, or { _selectedIds } for a selection.',
136+
type: 'flow',
137+
target: 'showcase_recalc_estimates',
138+
authRequired: true,
139+
};
140+
141+
export const allApis: ApiEndpoint[] = [TaskFeedEndpoint, InquiryPurgeEndpoint, RecalcEstimatesEndpoint];

0 commit comments

Comments
 (0)