Skip to content

fix(showcase): re-land the recalc as a declared flow endpoint that writes as its caller - #23277

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23128-recalc-declarative-flow
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23128-recalc-declarative-flow

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

…ites as its caller

The two `api` actions (showcase_recalc_estimate, showcase_recalc_selection)
posted to a code-mounted http.server route that wrote through ctx.ql with no
execution context, so the security layer refused every write as principal-less
and both answered 500.

Under ruling C-prime the recalc is now metadata: a `type: 'flow'` endpoint at
/api/v1/apps/showcase/recalc (authRequired) targets showcase_recalc_estimates,
which turns either request body into a list of ids and maps the per-task flow
showcase_recalc_task_estimate over it (get_record, an assignment computing the
window's inclusive days x 8 in CEL, update_record), both runAs 'user'. The
code-mounted route, its kernel:ready mount and its unit test are deleted, and
every showcase reader of the route is re-pointed.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
An admin's recalc answers 200 and writes the window's days x 8; a member
without edit rights on the row is refused with nothing written; the aggregate
_selectedIds body does both the same way, each task from its own window; an
anonymous caller is turned away with 401; a body naming no task is refused.
Also drops an apostrophe that broke the coverage note's string literal.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
A flow endpoint answers a failed run with the shared flow-dispatch table's
400 FLOW_FAILED, so the code alone does not say why. The refused cases now
also require the run's error to carry the refusal the data door answers the
same member for the same edit under 403 PERMISSION_DENIED, compared with that
live answer rather than a copied sentence.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests labels Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b4d573a0e8d228e10e54f0a75044658b2c0aca82 → packageMentionDocs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants