Skip to content

Elevate the escalation stamp via a system subflow, not the screen flow - #1575

Merged
os-musk merged 3 commits into
mainfrom
claude/issue-1434-escalation-stamping-subflow
Sep 3, 2026
Merged

Elevate the escalation stamp via a system subflow, not the screen flow#1575
os-musk merged 3 commits into
mainfrom
claude/issue-1434-escalation-stamping-subflow

Conversation

@os-musk

@os-musk os-musk commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1434

Implements the maintainer-approved option D (director seat, decision batch #21 ②): escalate_case keeps runAs: 'user', the elevated write moves into a dedicated runAs: 'system' sub-flow reached by a subflow node, the stamped fields become readonly: true, and the STAMPED_NOT_TYPED exemption plus its counter-pins are deleted.

Nothing users see changes. The Escalate Case button behaves exactly as before; what changes is the privilege of each individual write.

⭐ Step 1 was the ruling's premise, and it is CONFIRMED

The ruling hangs on one named, falsifiable claim: a callee flow's own runAs governs its writes, rather than inheriting the caller's context. It is measured against a real engine — real ObjectQL, real InMemoryDriver, real AutomationEngine — in test/readonly-write-semantics.test.ts, not read off the source.

Three assertions, sharing one parent shape and one child shape, with the child's declared runAs as the only variable:

measurement result
runAs:'system' callee invoked from a runAs:'user' parent write to a readonly column SURVIVES
the same callee declared runAs:'user' (negative control) write is STRIPPED
the parent's own later write to an identical readonly column, in the run where the child's stamp landed still STRIPPED

The second row is what makes the first a measurement of runAs rather than of indirection: without it, a surviving system-child write would be equally well explained by "a subflow hop launders the write", which would make the elevation boundary decorative. The third row is the property the card exists to protect — the elevation is scoped to the child run and the screen flow keeps the acting user's context. Every case also asserts a control column (open_flag) landed, so a "stripped" reading can never be a harness that silently wrote nothing.

The mechanism, for the record: the subflow executor genuinely hands the child the parent's context ({ ...context, $subflowDepth, params }), and resolveRunContext then re-asserts runAs: flow.runAs ?? 'user' after that spread — last-write-wins in the callee's favour. The vendor states the intent: "a COPY, never mutating the caller's context, so the elevation is scoped to this run and the caller's identity is restored when the run returns" (ADR-0049 / #1888).

The split, and the two decisions inside it

escalate_case's node wrote five fields. They are split by who may legitimately write them:

field writer after this change why
escalation_reason escalate_case (user) the agent's own screen input
priority escalate_case (user) an ordinary editable field
status escalate_case (user) user-writable and the hook trigger — see below
is_escalated case_escalation_stamp (system) readonly; nobody types it
escalated_date case_escalation_stamp (system) readonly; nobody types it

status: 'escalated' deliberately stays in the user-context node. Both escalation hooks key off it — case_escalation_reassign gates on input.status !== 'escalated' || previous.status === 'escalated', and case_status_side_effects reads the same transition. Keeping it there keeps the ownership hand-off and the follow-up tasks firing from the acting user's write, exactly as before. Moving it into the elevated subflow would have silently re-attributed both to the system.

escalation_reason stays writable. Declaring it readonly would make the platform strip what the agent just typed — this card's own harm, inverted onto user input — and escalation_reason_required would then reject the escalation outright.

⚠️ The ordering is load-bearing, and both orders were measured

Splitting one update_record into two creates an ordering constraint the single node did not have. escalation_reason_required rejects any write whose merged record has is_escalated == true with a blank reason; as one node the reason arrived in the same payload, but split they are two validation passes.

Chosen order: the reason is written FIRST, the stamp SECOND. Measured both ways over a probe object carrying the same predicate shape:

  • reason first, stamp second — both land;
  • stamp first (the counterfactual) — the stamp is refused by the validation, and because a subflow node reports its child's failure, the run aborts before the reason is written either, losing the whole escalation.

The shipped edge order is pinned, walking the edges rather than trusting array order.

Guard removal

STAMPED_NOT_TYPED held exactly one entry, crm_case.is_escalated, and existed only because a user-context flow wrote the field. With the cause removed, the exemption and its read site are deleted: isAuthorable() now skips both fields for the right reason — they are declared readonly — rather than via an exemption list. Both counter-pins that existed to keep the exemption's justification honest are removed with it. ⛔ No new gate was added.

Ruling item 3's still-owed half is done: close_case now says in the file that its flow-level elevation is a historical precedent, not a policy, so it stops being citable as a pattern for elevating the next screen flow.

⚠️⚠️ Scope deviations — declared, and the seat should review these first

The dispatched surface was 6 files. This PR touches 13. Every extra file is a mechanical consequence of the ruling rather than a discretionary choice, but none of them was in the brief, and each is individually revertible:

  1. test/case-create-form-narrowing.test.ts — a FOURTH counter-pin the site count missed. The dispatch listed three sites to keep consistent; this file holds a fourth, pinning is_escalated/escalated_date as NOT readonly (test/readonly-write-semantics.test.ts's own header names it, so it is documented, just not enumerated). It cannot be left: it directly contradicts the ruling and fails the build. Narrowed to is_sla_violated, whose rationale never depended on the platform.
  2. src/flows/index.ts — the barrel. A new flow file that nothing exports is dead code, and the subflow node resolves its target off the engine registry.
  3. test/flow-case-actions.test.ts — the existing runtime tests asserted the old single-node shape.
  4. test/automation-docs-coverage.test.ts + the three automation pages + README.md + docs/STATUS.md — registering a 27th flow moves every stated flow count (26 → 27) and needs a table row in three locales. autolaunched is also a new flow type for this repo, so the docs guard needed a trigger word for it.

None of these files is claimed by the sibling agents (#1436 holds AGENTS.md; #1481 holds test/helpers/metadata-fixtures.ts and src/apps/crm.app.ts). AGENTS.md was not touched.

Verification

Every component of pnpm verify run individually on the final tree, 1827886:

validate        EXIT=0   ✓ Validation passed (1451ms) — no diagnostic names the new flow
typecheck       EXIT=0   (--listFiles confirms all 3 edited test files are in the compiled set)
lint            EXIT=0   49 warning(s), 10 suggestion(s) — pre-existing
lint:i18n-gate  EXIT=0   ✓ 0 `i18n/missing-*` issues
hygiene         EXIT=0   ✓ source hygiene clean
hygiene:tokens  EXIT=0   ✓ ~135,827 tokens (ceiling ~140,000; headroom ~4,173)
build           EXIT=0   Logic: 27 Flows
test            EXIT=0   160 files, 3404 passed | 1 skipped

⚠️ The token ratchet has ~4,173 headroom left; this change consumed a meaningful share of it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YDbLQQiy7ew8sdwiCQEZwC


Generated by Claude Code

…e screen flow

`crm_case.is_escalated` and `escalated_date` are now `readonly: true` — the
honest declaration, since only flows write them. The write that needed
elevation moved into a dedicated `runAs: 'system'` sub-flow
(`case_escalation_stamp`) reached by a `subflow` node, so the `escalate_case`
screen flow keeps `runAs: 'user'` and the acting agent keeps their identity.

The `STAMPED_NOT_TYPED` guard exemption and both of its counter-pins are
deleted: the cause is gone rather than documented.

Measured on a real engine, not assumed (all pinned in
test/readonly-write-semantics.test.ts):

- a runAs:'system' callee invoked from a runAs:'user' parent has its write to a
  readonly column SURVIVE, while the same callee declared runAs:'user' is
  STRIPPED — so the callee's own declaration decides, not the subflow hop;
- the elevation is scoped to the child run: the parent's own later write to an
  identical readonly column is still stripped;
- the reason must be written BEFORE the flag flips, or
  escalation_reason_required refuses the stamp and the run aborts before the
  reason lands either.

`status: 'escalated'` stays in the user-context node — it is the transition both
escalation hooks key off, so the ownership hand-off keeps firing from the
acting user's write. `escalation_reason` stays writable: declaring it readonly
would strip what the agent just typed.

Refs #1434

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDbLQQiy7ew8sdwiCQEZwC
…ards

Registering `case_escalation_stamp` is a 27th flow, and several guards count or
list the shipped set. All of these are mechanical consequences of the #1434
split rather than independent choices:

- `test/flow-case-actions.test.ts` runs `escalate_case` WITH its callee
  registered (a subflow node resolves its target off the engine registry), and
  gains an anti-vacuity case pinning that the parent no longer writes the
  stamps and the child does. Its header now states what this harness does NOT
  measure: it has no readonly semantics, so the strip is measured in
  test/readonly-write-semantics.test.ts instead.
- `test/automation-docs-coverage.test.ts` learns the `autolaunched` trigger
  kind (new to this repo) with a word in all three locales, plus the row label.
- The three automation pages gain a row; every stated flow count goes 26 -> 27
  (the three pages, README banner//summary/tree, docs/STATUS.md).

Refs #1434

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDbLQQiy7ew8sdwiCQEZwC
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 3, 2026 5:05pm UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Sep 3, 2026
`...(alsoRegister as never)` is TS2698 on every TypeScript version — `never` is
not an object type, so it cannot be spread. The intent is "spread a record of
extra flows", so the cast now names one, matching the `flow as never` idiom
already used for the single-value entry beside it.

Caught by CI on 1827886, not locally: the local `typecheck` green cited in the
report predated this line. `--listFiles` established that the file was in the
check's SCOPE and that was allowed to stand in for the check being CURRENT —
two different properties. This run of `pnpm typecheck` started 38s after the
file's mtime and is the last command before this commit.

Refs #1434

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDbLQQiy7ew8sdwiCQEZwC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants