Repository navigation
fix(showcase): re-land the recalc as a declared flow endpoint that writes as its caller - #23277
Merged
objectstack-fleet[bot] merged 5 commits intoOct 13, 2026
Merged
Conversation
…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>
…calc-declarative-flow
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>
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift CheckNothing 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): |
objectstack-fleet
Bot
deleted the
claude/issue-23128-recalc-declarative-flow
branch
October 13, 2026 14:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #23128
Clause-②: no
What this changes
The showcase's two
apiactions, Recalculate Estimate (showcase_recalc_estimate) and Recalculate Selection (showcase_recalc_selection), posted to a code-mountedhttp.serverroute. That route wrote throughctx.ql.updatewith 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:showcase_recalc_estimates_apiinsrc/system/apis/index.ts:POST /api/v1/apps/showcase/recalc,type: 'flow',authRequired: true, targetshowcase_recalc_estimates.showcase_recalc_estimates(type: 'screen', the showcase's existing precedent for an on-demand entry behind an endpoint,runAs: 'user'). Anassignmentturns either request body into a list of ids. Amapnode then runs the per-task flow once per id.showcase_recalc_task_estimate(type: 'autolaunched',runAs: 'user'):get_recordreads the row's ownstart_date/end_date;assignmentcomputes the window's days × 8 in CEL;update_recordwritesestimate_hours./api/v1/apps/showcase/recalc.src/system/server/recalc-endpoint.ts, its import and itskernel:readymount inobjectstack.config.ts, and its unit testtest/recalc-endpoint.test.ts.⛔ Not a system context. Triage
6137971940holds, and the ablation below shows whatsystemwould allow.The computation, measured
The CEL in the
assignment(WINDOW_ESTIMATEinsrc/automation/flows/recalc-estimate.flow.ts):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 000exactly as the oldMath.round(...)did.{ recordId }, so the old route always fell back to 8 on that path. Its bulk branch wrote a flat 8 per id.bootShowcase, automation on):136;40;8.200 {"success":true,"data":{"success":true,"output":{"estimates":[{"estimate_hours":136}]}, …}}.scriptnode needed. The ruling's fallback did not arise:assignment.assignments.*andupdate_record.fields.*are CEL value slots (flow-node-expression-paths.ts, rolevalue), anddaysBetween/has/isBlankare registered stdlib.runAsThe 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'sgetFlow:showcase_recalc_estimates→{type:'screen', runAs:'user'}showcase_recalc_task_estimate→{type:'autolaunched', runAs:'user'}The
mapchild 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
1fb1ced36243, read only):apiHandlerinpackages/app-shell/src/hooks/useConsoleActionRuntime.tsxtakes an absolutetargetand POSTs it through the authenticated fetch.recordIdParam.execution: 'perRecord'):{ recordId }.bulkActionDefswithexecution: 'aggregate'): ONE request with{ _selectedIds: [...] }.buildAutomationContext(packages/runtime/src/domains/automation.ts). Flat keys becomeparams, sorecordIdand_selectedIdsseed the entry flow's twoisInputvariables (each defaults tonull).maprunsshowcase_recalc_task_estimateonce per id, sequential and fail-fast (map-node.ts). The single-record body is a list of one.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."400), so a partial selection is never reported as success.{}and{ _selectedIds: [] }both become[null]. The per-taskget_recordthen refuses a filter operand that evaluates to nothing, so the request answers400and does not widen the read.The pins (dogfood, real boot)
Section 3b of
packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.tsreuses that file's showcase boot. The refused caller is a signed-up member holding only theeveryonebaseline (showcase_member_default:showcase_taskread + create, no edit).200), and the data door refuses its edit with403 PERMISSION_DENIED.200and writes136. An unnamed row is untouched.400witherror.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.{"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, …}}.{ _selectedIds: [a, b, c] }answers200and writes136 / 40 / 8, each task from its own window.401before 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's400 FLOW_FAILED(packages/runtime/src/flow-dispatch-status.ts, the same answer asPOST /automation/:name/trigger). That is becauseupdate_record's catch returns the message without acode. The pins hold what the platform answers, and this is raised for the PM in the report.Readers of the deleted route
objectstack.config.tsonEnablemount removed, theapis:comment re-pointedsrc/system/apis/index.tssrc/ui/actions/index.tssrc/ui/views/task.view.tssrc/coverage.tssrc/docs/showcase_tour_system.mdtest/gap-fill.test.tsrunAs: 'user'test/recalc-endpoint.test.tssrc/system/translations/index.tssrc/ui/pages/task-detail.page.tsVerification at
2ce73b3234node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) on2ce73b3234derives 58 commands. All 58 exit 0, with exit codes captured before any pipe.--ranreconciliation:✓ 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).pnpm --filter @objectstack/example-showcase exec vitest run --maxWorkers=2→Test Files 32 passed (32),Tests 408 passed (408),VERDICT command-exit 0.pnpm --filter @objectstack/example-showcase run typecheck→VERDICT command-exit 0.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.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/showcase-declarative-endpoints.dogfood.test.ts→Tests 24 passed (24),VERDICT command-exit 0.pnpm --filter @objectstack/dogfood run typecheck→VERDICT command-exit 0.tsc --listFilescontains both the test file andrecalc-estimate.flow.ts(1 hit each).pnpm lintis CI's):isPathIgnored=falseand 4 to 5 active rules.eslint --no-inline-config --format jsonover those 9 files → 9 files, 0 errors, 0 warnings.parserOptions.project/projectServiceineslint.config.mjs), so this diff cannot move a verdict on an untouched file.Ablations (each through
scripts/ablation-replace.mjswith an anchor that hit once, under a restore trap; restoration proven by blob == HEADaa606566b4b5and an emptygit diff HEAD):runAs: 'user'→'system'onshowcase_recalc_task_estimate.systemthe member's single recalc answered200withacted: 1, and its selection200withacted: 3. The member was writing rows it may not edit.+ 1dropped from the assignment.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 isCheck Changeset(.github/workflows/pr-automation.yml), whose route 2 ("It releases nothing … examples/, tests-only") is theskip-changesetlabel. That label is not applied by this PR; see the report.Acceptance notes
packages/spec/src/migrations/entries/semantic/18.plugin-manifest-contributes-routes-retired.ts:13: thereplacementtext an author is shown says "examples/app-showcasemounts POST /api/v1/showcase/recalc that way";packages/spec/src/api/plugin-rest-api.zod.ts:36-37and its generated copycontent/docs/references/api/plugin-rest-api.mdx:37;packages/spec/liveness/manifest.json:194note;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 scanspackages/**.400 FLOW_FAILED, not403 PERMISSION_DENIED, becauseupdate_record's catch (service-automationcrud-nodes.ts) returns the message without acode. The flow-dispatch table declaresFLOW_FAILEDfor every run that ran and failed, so this is the declared contract, not a defect of it.Generated by Claude Code