Repository navigation
Commit 042ef51
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
- src
- automation/flows
- docs
- system
- apis
- server
- ui
- actions
- views
- test
- packages/qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
21 | 20 | | |
22 | 21 | | |
23 | 22 | | |
| |||
256 | 255 | | |
257 | 256 | | |
258 | 257 | | |
259 | | - | |
260 | | - | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
| |||
293 | 293 | | |
294 | 294 | | |
295 | 295 | | |
296 | | - | |
297 | | - | |
298 | 296 | | |
299 | 297 | | |
300 | 298 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
2012 | 2013 | | |
2013 | 2014 | | |
2014 | 2015 | | |
| 2016 | + | |
| 2017 | + | |
| 2018 | + | |
| 2019 | + | |
2015 | 2020 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
130 | 130 | | |
131 | 131 | | |
132 | 132 | | |
133 | | - | |
| 133 | + | |
134 | 134 | | |
135 | 135 | | |
136 | 136 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
42 | 46 | | |
43 | 47 | | |
44 | 48 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
11 | | - | |
| 10 | + | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | | - | |
| 51 | + | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
56 | | - | |
| 56 | + | |
57 | 57 | | |
58 | | - | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
59 | 62 | | |
60 | 63 | | |
61 | 64 | | |
| |||
114 | 117 | | |
115 | 118 | | |
116 | 119 | | |
117 | | - | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
0 commit comments