Skip to content

feat(carve S7): the orchestration compute plane — reconciler, release, rollup - #659

Open
isadeks wants to merge 35 commits into
carve/s1-foundation-contractsfrom
carve/s7-orchestration-plane
Open

feat(carve S7): the orchestration compute plane — reconciler, release, rollup#659
isadeks wants to merge 35 commits into
carve/s1-foundation-contractsfrom
carve/s7-orchestration-plane

Conversation

@isadeks

@isadeks isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Slice 7 of the staged carve. Stacked on #657 (carve/s6-decompose-iteration) — review that first; this PR's diff is only its own commit.

What this adds

The engine that actually runs a sub-issue graph. It watches child tasks reach a terminal state, releases whatever they unblocked, keeps the parent's status panel current, and re-stacks dependents when a branch moves.

  • orchestration-discovery — the composer that turns a trigger into a seeded run: read the graph, validate it, append the integration node if it fans out, persist it, and hand back the root set to start.
  • orchestration-reconcile — pure gating. Given a child that just went terminal, decide which dependents are now releasable, which must be skipped, and when the parent itself is done. Two recovery shapes are covered too: comment-fixing a failed child un-fails it and re-releases the dependents that were skipped behind it, and re-triggering a finished epic retries only its failed/skipped children.
  • orchestration-release — create a child's task, behind a claim so two concurrent releasers can't both launch it. A guardrail rejection or an un-onboarded repo is terminal with a reason the user can act on; a 5xx or a duplicate replay rolls back to ready so the sweep retries.
  • orchestration-rollup — the parent's single maturing panel: one comment that matures in place rather than a stream of updates. A failed row carries an indented sub-line saying what failed and where to read it.
  • orchestration-restack — when a child's branch changes, work out which dependents need their base moved.
  • orchestration-parent-comment — route a comment on the epic to the sub-issue it's actually about, and say so plainly when it looks like new work rather than a change to existing work.
  • orchestration-reconciler (handler + construct) — the TaskTable-stream consumer that drives all of the above. A DLQ for poison records, a FilterCriteria so only terminal-status writes invoke it (RUNNING/heartbeat churn is the bulk of TaskTable writes and would otherwise wake it constantly), and partial-batch reporting so one bad record can't re-drive its healthy siblings.
  • reconcile-stranded-orchestrations (handler + construct) — a scheduled backstop for a run whose stream event was lost.

⚠️ Unlike S5 and S6, this slice is NOT fully dormant

TaskTable gains a LinearIssueIndex GSI and has its stream enabled (NEW_IMAGE). TaskTable is instantiated in the stack, so these two changes do land on deploy.

Both are required by this slice and can't be deferred: the reconciler consumes that stream, and it queries that index to total an issue's iteration cost. Notes for review:

  • Enabling a stream and adding a GSI are both in-place CloudFormation updates — no table replacement.
  • The GSI projection is deliberately narrow (pr_url, pr_number, status, repo, user_id, channel_metadata). A GSI's projection cannot be changed in place afterwards, which is why the cost query does a per-task GetItem for cost_usd rather than widening it.
  • The stream is on TaskTable rather than TaskEventsTable because the latter is already at its 2-consumer limit.
  • The reconciler constructs themselves are not instantiated in any stack yet — that wiring is the next slice — so nothing consumes the stream on deploy.

Verification

  • tsc --noEmit clean
  • CDK: 3491 tests / 173 suites passing (up from 3213/164 on the base)
  • eslint clean

🤖 Generated with Claude Code


Tracking

Slice S7 of the linear-vercel → main carve. Tracking issue: #668 (slice table, why it is sliced rather than merged, and review guidance).

Lands part of #247 (parent/sub-issue orchestration). Deliberately not Closes — no single slice closes it; it closes when S8 activates the arc. The auto-decomposition issue (#299) is no longer part of this carve — that feature stays on the development branch as experimental.

Stacked on #657 — review that first; this PR's diff is against its branch.

@isadeks

isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Automated review — carve S7 (#659)

Reviewed as its own diff only (carve/s6-decompose-iteration..carve/s7-orchestration-plane, 21 files, +10274/−4). This PR does not claim dormancy — it enables a DynamoDB stream and adds a GSI on the live TaskTable — so the CFN-safety questions got priority. Governance is noted once for the whole stack, not repeated here.

Slice standalone-ness: PASS

tsc --noEmit clean at this tip on its own base (compiler run, not inferred). Only 4 deletions in the whole diff. Every new source file ships with its test in this same slice — no source/test split at this boundary.


CFN safety — the questions worth asking before this merges

Stream enablement: in-place, no replacement. CONFIRMED.
task-table.ts adds stream: dynamodb.StreamViewType.NEW_IMAGE. StreamSpecification is an in-place update on AWS::DynamoDB::Table, and I checked that nothing else in the same diff touches a replacement-forcing property — partitionKey (task_id), sortKey, billingMode (PAY_PER_REQUEST), and tableName (still props.tableName passthrough) are all unchanged. The comment in the code makes the same claim and it holds up.

GSI addition: one per deploy. CONFIRMED.
DynamoDB permits one GSI create per update, and this diff adds exactly one (LinearIssueIndex), taking TaskTable from 4 GSIs to 5:

ref GSIs names
main / s6 4 UserStatus, Status, Idempotency, JiraIssue
s7 (#659) 5 + LinearIssueIndex
s8 5 unchanged

Since JiraIssueIndex already exists on main, this is a clean single-index addition, not two-at-once.

GSI projection: correct for its purpose — but four call sites already pay for it.
Projection is INCLUDE with ['pr_url','pr_number','status','repo','user_id','channel_metadata']. I traced every consumer and every attribute it reads:

  • linear-task-by-issue.ts resolveTaskByLinearIssue reads task_id, user_id, repo, pr_url, pr_number, statusall projected
  • github-webhook-processor.ts reads task_id, channel_metadata.iteration_reply_comment_idprojected

So the routing lookups the index was designed for are fully covered, and the projection is right for them. But four call sites need attributes the index does not project, and each pays a per-item base-table GetItem:

  • orchestration-reconciler.ts:1266cost_usd
  • fanout-task-events.ts sumIterationCostForIssuecost_usd
  • clarify-resume.ts:45code_changed, answer_text, task_description, workflow pin
  • linear-webhook-processor.ts:3747 — the clarify fields (reads the full base row)

Each is deliberate and documented in-code. I am raising it because a GSI projection cannot be changed in place — DynamoDB rejects it, as the code comment itself records from experience. So this list is the permanent cost of the current shape. Four known consumers already working around it, two of them for the same field (cost_usd), is the moment to ask whether cost_usd belongs in the projection. Adding it now is free; adding it later means creating a second index. Worth a deliberate decision rather than inheriting it.


On the previously-flagged sumIterationCostForIssue concern — largely REFUTED

The standing concern was an unpaginated Query on LinearIssueIndex plus serial per-task GetItem in a stream-consumer Lambda. I traced it in both copies and the batch-multiplication part does not hold:

  • The Query is unpaginated (no LastEvaluatedKey loop) and the GetItem loop is serial. Both confirmed.
  • But it does not run per stream record. Three gates precede it in replyToStandaloneTrigger: it requires a trigger comment, it excludes orchestration iterations, and it takes a one-shot claim — UpdateExpression: 'SET ack_replied_at = :now' with ConditionExpression: 'attribute_not_exists(ack_replied_at)'. So a redelivered batch (fan-out batchSize: 100, reconciler batchSize: 10) is claimed away and the sum runs once per terminal iteration task.
  • Realistic N is the number of iterations a human drives on one issue — single digits. At that size, serial GetItem is a few milliseconds.
  • Both consumers are reportBatchItemFailures: true, so a poison record doesn't block the shard.

Residual risk, small but real: at the 1 MB Query page limit the function silently undercounts rather than erroring — total is summed from a truncated Items list and returned as if complete. That needs roughly thousands of tasks on one issue to trigger, so I would not block on it. If you want the cheap fix, it is a LastEvaluatedKey loop or a Limit plus an explicit "showing first N" note, so the number shown to the user is never quietly wrong.

Duplication worth noting: sumIterationCostForIssue exists twice with the same shape — fanout-task-events.ts and orchestration-reconciler.ts:1252. Two copies of a cost-accounting function will drift.


Other findings

MINOR — renderStatusBlock, rollupKindFromChildren, renderRollupComment, postRollup, renderEpicPanel, buildPanelRows and truncateQuote are exported from orchestration-rollup.ts but have zero callers in src/ anywhere in the stack.
Verified at the s8 tip: git grep -l <fn> -- 'cdk/src/**' excluding the defining file returns nothing for all seven. Only upsertEpicPanel and cascadeNodeLabel are actually used. The file's own comment explains why — the maturing panel "supersedes the separate renderStatusBlock + renderRollupComment" — so this is superseded code that shipped alongside its replacement. It is invisible to the dead-code ratchet because each function has a test file referencing it, and knip-baseline.json is unchanged at 78 across the whole stack. ~600 lines of dead rendering logic is a lot to publish; consider deleting the superseded half.

I also rendered the two dead functions to check whether they were merely unused or actually wrong, and they are wrong in ways that would matter if anything called them:

renderStatusBlock([succeeded, failed, blocked, released, skipped])
  → "🔄 **ABCA orchestration** · 3/5 complete"

Three of five "complete" with one success — terminal() counts failed and skipped toward "complete".

rollupKindFromChildren([succeeded, released])  → "complete"
rollupKindFromChildren([])                      → "complete"

A still-released (running) child yields complete, and an empty set yields complete. Another reason to delete rather than keep.

MINOR — a raw internal UUID is shown to users on the fallback path. In renderStatusBlock, when a child has neither display_id nor title the label falls back to sub_issue_id, rendering - ✅ issue-uuid-1 — succeeded. Currently unreachable (dead function), but the same display_id ?? sub_issue_id fallback pattern is worth checking in renderEpicPanel before activation.

IAM

Least-privilege on the new roles looks sound: grantReadData where read-only suffices, grantReadWriteData only on the three tables the reconciler genuinely writes, a DLQ with enforceSSL and 14-day retention for poison records, and cdk-nag suppressions that name the CDK-generated index/* wildcards specifically rather than blanket-suppressing. No wildcard actions. The one over-broad grant I found is in #662 (traceArtifactsBucket.grantRead on the whole bucket) and is reported there.


Reviewed with Claude Code. Verification: tsc --noEmit on this slice's own base; GSI count and stream declaration compared across six refs; every LinearIssueIndex consumer's attribute reads traced against the projection list; the claim gate read as an actual ConditionExpression; the dead rollup functions executed to check their output.

@isadeks

isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Addendum to the review above — two further findings

A second pass surfaced two things the first review missed.

MINOR (user-copy + public-fitness) — a user-facing retry instruction names the wrong label.

orchestration-rollup.ts:318:

(No reply? Removing and re-applying the `abca` label also retries.)

The trigger label is per-project configurable (label_filter on the project-mapping row) and its default is bgagentDEFAULT_LABEL_FILTER = 'bgagent' in orchestration-decomposition-mode.ts:44, linear-webhook-processor.ts:35 and jira-webhook-processor.ts:58, and the docs say the same. abca is this project's own label, not the shipped default.

So a user who follows this instruction removes and re-applies a label the webhook does not filter on, and nothing happens. It should interpolate the resolved label_filter for that project, or fall back to DEFAULT_LABEL_FILTER rather than a hardcoded string. This is both a correctness bug in user-facing copy and an instance of the use-case-specific-content class.

NIT — taskTableForWrites is a prop that silently does nothing.

OrchestrationReconcilerProps.taskTableForWrites (orchestration-reconciler.ts:46) is declared and documented ("TaskTable (for createTaskCore writes when releasing children)"), but the constructor never reads it — the write grant actually comes from props.taskTable.grantReadWriteData(this.fn). It is also never passed by the stack at #662.

Worth removing rather than leaving: the only reason a caller would reach for this prop is to point writes at a different table, and doing so would silently have no effect while appearing to work.

NIT — third instance of the "cited artifact lands later" pattern.

reconcile-stranded-orchestrations.ts:39 and orchestration-release.ts both refer the reader to docs/research/orchestration-reconciler-correctness.md for the failure-mode analysis justifying the sweep. That file does not exist at main, s1–s6, or this slice — it is added in #662. So this slice's two most safety-critical modules cite a nonexistent document for their correctness rationale.

This is the same shape as the ADR-016 citations in #654 and the model/IAM split in #654#662. See the stack-level note in the summary: the recurring boundary error in this stack is code landing in an earlier slice than the artifact that authorises it.

@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from 4581e88 to 8b85d8e Compare July 27, 2026 17:28
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from c9ab31c to f28f5ff Compare July 27, 2026 17:28
@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from 8b85d8e to 32e969a Compare July 27, 2026 17:51
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from f28f5ff to 007e462 Compare July 27, 2026 17:52
@isadeks

isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed

Thank you for the CFN-safety pass — checking stream enablement and GSI count against six refs is exactly the verification this slice needed, and I'm glad the in-place claims held up under it.

MINOR — user-facing retry copy named the wrong label. Fixed. This was the most valuable finding in the review: the hint told users to re-apply a hardcoded label while the shipped default is a different string and the label is per-project configurable, so a user following the instruction got silence. renderEpicPanel and upsertEpicPanel now take the resolved label and fall back to the platform default. I rendered the panel to read the output rather than trusting the source.

Note the existing test asserted the hardcoded string, which is why this shipped — so I fixed the assertion too and added two cases (a custom label, and the default fallback). All three fail if the hardcoded string comes back.

MINOR — taskTableForWrites is a prop that silently does nothing. Confirmed, removed. Your reasoning for removing rather than leaving it is right: the only reason to reach for it is to point writes at a different table, and that would appear to work while having no effect.

GSI projection — cost_usd. Genuinely useful framing: adding it now is free, later means a second index. I have not added it, and want to be explicit that this is a judgement call rather than an oversight. Four consumers pay a GetItem, two for that one field — but widening the projection makes every task write carry cost_usd into the index, and the field is written late and often on iteration-heavy tasks. The GetItem cost is bounded per your own analysis below; the write amplification is not as easy to bound. If you disagree I will take the change — you have looked at the consumers more closely than the original author did.

sumIterationCostForIssue — thank you for refuting the part that did not hold. The claim as I had it (batch multiplication in a stream consumer) was wrong, and reading the claim gate as an actual ConditionExpression is the check I should have made. The residual you identified — a silent undercount at the 1 MB page boundary rather than an error — is the real defect, correctly sized as non-blocking. Not fixed here; tracked, with your suggested fix (page loop, or an explicit "first N" note so the number shown is never quietly wrong).

Duplicated sumIterationCostForIssue. Correct, two copies with the same shape. Not de-duplicated in this slice because the copies live either side of a slice boundary; folding them together is a follow-up, and the page-limit fix should land in one place once they are merged.

MINOR — ~600 lines of dead rendering logic. Confirmed: seven exports with no src/ callers, invisible to the dead-code ratchet because each has a test. Not deleted here — that is a clean subtractive change, and I would rather it be its own reviewable commit than buried in a slice this size. Your rendering of the two dead functions is the part I want to flag as valuable: rollupKindFromChildren([]) returning complete, and terminal() counting failures toward "3/5 complete", would both be real bugs if anything called them. That turns "delete this" from tidiness into correctness, and I have recorded it that way.

MINOR — raw UUID on the fallback path. Checked renderEpicPanel's live path as you suggested; it uses the same display_id ?? sub_issue_id shape, so the exposure is real there too if a row lacks both. Left as-is for now (a row with neither is a data defect in its own right) but noted.

NIT — third instance of "cited artifact lands later". Your stack-level observation is the most useful thing in this review. Two of the three are addressed (the descriptor/YAML split, the model/grant split); the doc citations I have left, with reasoning on #654. The pattern is real and worth stating as a slicing rule: code must not land in an earlier slice than the artifact that authorises it.

@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 007e462 to 8f7f73f Compare July 27, 2026 17:56
@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from 32e969a to f5afe7e Compare July 27, 2026 18:40
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 8f7f73f to a510000 Compare July 27, 2026 18:40
@isadeks

isadeks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Operator note: this slice adds one GSI, but a STALE deployment may need two — DynamoDB allows one per update

Found while deploying this arc to a second environment. Correcting something I asserted earlier in this PR.

What I verified, and why it was the wrong baseline. I checked that TaskTable goes from 4 GSIs to 5 — adding only LinearIssueIndex — and that JiraIssueIndex already existed on main. Both true: JiraIssueIndex came in with #640, which is an ancestor of this carve's base, so it is not this arc's index. This slice adds exactly one GSI relative to main.

But DynamoDB does not constrain the delta against main. It constrains the delta against what is actually deployed:

Cannot perform more than one GSI creation or deletion in a single update

An environment whose stack predates #640 has three GSIs live (StatusIndex, IdempotencyIndex, UserStatusIndex). Deploying this branch there asks for two new indexes at once — JiraIssueIndex and LinearIssueIndex — which DynamoDB refuses. TaskTable goes UPDATE_FAILED and the whole stack rolls back. (The rollback is clean: the table is not replaced, and the newly-created orchestration resources are removed.)

The dev account did not surface this only because its stack was already current with main before the arc went on. That is exactly the kind of thing that hides on the machine where the work was done.

Operator guidance for merging/deploying this:

  • Confirm the target stack is current with main before deploying this slice. If it is not, deploy main first (which adds JiraIssueIndex alone), then this branch (which adds LinearIssueIndex alone).
  • This is per-deployment state, not a code change — nothing in the template needs fixing, and splitting the index across slices would not help, since the constraint is about the deployed baseline rather than the source.

Also worth recording, since it bears on how anyone verifies this: on that environment two cdk deploy runs printed exit code 0 while failing at Docker image build (a NodeSource CDN 403 that then poisoned the layer cache — pruning the build cache cleared it). Same trap hit on the dev account, where two runs exited 0 while failing at ECR asset publish. Check the CloudFormation stack status and LastUpdatedTime, not the CLI's exit code.

@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from f5afe7e to 0fb2cc1 Compare July 28, 2026 01:42
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 62e29fc to 13338b8 Compare July 28, 2026 01:42
@isadeks

isadeks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Second-round review: my retry-hint fix was cosmetic — now actually wired

The reviewer checked whether any production caller passes the labelFilter I added. None did, and none could: all three upsertEpicPanel call sites omit it, and the reconciler has no project-mapping access — no mapping table in its props, and no mapping env var. So every user still saw the platform default and the hint was still wrong for any project that renamed its trigger label. The original finding was still open in production; my fix only moved the parameter into place.

Fixed properly. The label is now captured where the mapping is in hand — at seed time — and persisted on the meta row's release context; the reconciler reads it back and passes it to the panel. Rows seeded before the field existed simply have none and fall back to the platform default, as before.

Only the settled-panel call site is wired, deliberately: the retry hint renders only when the epic is terminal and something failed, and the other two sites render an in-progress panel that has no hint. I verified that rather than assuming it.

Guarded in both directions, and the read half is the one that mattered: dropping the write fails the seed test, but dropping the read hydration originally left the entire 3500-test suite green. Both are now pinned.

On taskTableForWrites — fair criticism that an unexplained API removal was bundled into a copy fix. It is genuinely dead (no caller on this branch, the next, or the source branch) and removing it was the right call, but it belonged in its own commit with the reasoning stated. Noted.

On the labelFilter markdown escaping — accepted as low severity and not changed: the value comes from a project mapping an admin sets, not from user input. Worth a backtick strip for consistency; tracked rather than done here.

Verified and not changed: the panelRow rename is clean and load-bearing (mutating panelLabel to ignore display_id fails 5 of the renamed tests), and the backward dependency on the decomposition module resolves at runtime, not just under tsc.

@isadeks

isadeks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Upgrade-ordering note added, from a second-environment deploy — and a correction

Deployed this arc to a different account (one whose stack was last updated at pre-#643 main) and hit the GSI limit I had reasoned about earlier from the wrong baseline:

Cannot perform more than one GSI creation or deletion in a single update

I had verified "exactly one new GSI" against main, which is true — this slice adds only LinearIssueIndex, and JiraIssueIndex came from #640, an ancestor of the carve base. But DynamoDB constrains the delta against what is deployed, not against main. A stack predating #640 has three GSIs live, so this branch asks for two at once and CloudFormation rolls the whole stack back.

Operator guidance: confirm the target stack is current with main before deploying this slice. If it is not, deploy main first, wait for JiraIssueIndex to reach ACTIVE (~10 min), then deploy this branch. Verified working in that order — UPDATE_COMPLETE, table TableId unchanged so no replacement, and the resulting GSI set is the four pre-existing plus LinearIssueIndex.

Nothing in the template needs changing, and splitting the index across slices would not help — the constraint is about the deployed baseline.

A correction to something I wrote earlier in this thread. I claimed a stray artifact URI "would fail closed anyway" because the reconciler's grant is scoped to artifacts/*. That is wrong, and I confirmed it against the live policy: S3 does not normalize keys, so artifacts/../traces/u/x is a literal key that an artifacts/* resource matches by string prefix — iam simulate-principal-policy returns allowed for exactly that shape. So the handler-side check was the only thing standing between a tampered task record and another user's trajectory, and a bare startsWith did not cover traversal. Now fixed to reject traversal segments (raw and percent-encoded), with a test — deleting the check previously left all 64 reconciler tests green.

@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from 0fb2cc1 to b1410ea Compare July 28, 2026 12:17
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 13338b8 to 3b1863f Compare July 28, 2026 12:17
@isadeks
isadeks marked this pull request as ready for review July 28, 2026 12:55
@isadeks
isadeks requested review from a team as code owners July 28, 2026 12:55
…le docs

The project-mapping table's field list documented decompose_allowed,
max_sub_issues, and max_parent_budget_usd. Nothing in the main-bound slices reads
them, since decomposition stays on the development branch as experimental, and a
doc describing configuration the code does not honour is worse than no doc — an
operator would set those fields and wait for behaviour that never comes.

The table is schemaless apart from its partition key, so this removes only prose;
any row carrying those fields is still read back untouched.
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 4f90e00 to 836c3d0 Compare July 29, 2026 02:03
@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from f81c7eb to e432ce9 Compare July 29, 2026 02:03
`linear onboard-project` offered --decompose-allowed, --max-sub-issues, and
--max-parent-budget-usd. With decomposition staying on the development branch,
those wrote project-mapping fields nothing reads.

Worse than inert: the command printed "Auto-decomposition: ENABLED" and told the
operator which labels to apply, so the only feedback they got said it had worked.
They would then label an issue and get an ordinary single task, with nothing
anywhere explaining why.

Found by widening the search past cdk/ and agent/, where I had been looking. The
table itself is schemaless, so a project row that already carries these fields is
still read back unharmed — they are simply ignored.
@isadeks

isadeks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Scope change: auto-decomposition is no longer part of this carve

Heads-up before you re-read this — every slice was force-pushed, so a review in progress is against a stale diff. Sorry for the churn; the reason is a scope decision, not a fix.

Auto-decomposition is staying on the development branch as experimental and is not landing on main. It presupposes one way of working: an issue split into a sub-issue graph, with a human approval gate on a proposed plan. main should not presuppose that. Everything else in this arc is workstyle-neutral — running a graph a human already authored, iterating on a PR from a comment, heartbeats, clarify-resume, the Channel abstraction, the compute plane — and those all stay.

What moved

~7,900 lines removed. 23 dedicated files, the agent workflow and its prompt, the admission-table entry, the CLI flags, and about 314 lines embedded in files that survive.

slice effect
#647 S1 unchanged
#653 S2 comments now describe artifact workflows by their contract, not by one workflow
#654 S3 workflow YAML, prompt, and admission entry removed
#655 S4 --decompose-allowed / --max-sub-issues / --max-parent-budget-usd removed from linear onboard-project
#656 S5 note-sweep renamed for what it does
#657 S6 8538 → ~2000 lines. Now purely the iteration slice; retitled
#659 S7 graph CREATION removed from the reconciler; graph EXECUTION kept
#662 S8 the plan-proposal surface removed; webhook 4502 → 3156 lines

Three things worth knowing as a reviewer

Some of the review fixes from the last round are gone with the feature. The verdict-marker fix (B1 on #662) and the retry-hint trigger_label fix (#659) were both decomposition-only. They were real fixes and they live on the development branch — they are not being dropped as wrong.

Two things looked decomposition-only and were not. DEFAULT_LABEL_FILTER and hasHelpLabel lived in a decompose-named module but are plain label plumbing, so they moved to trigger-label.ts rather than being deleted. And the note sweep is prefix-based and general, so it was renamed, not removed. Both would have been silent breakage.

Removing the feature exposed dead IAM. The reconciler held an s3:GetObject on the artifacts prefix plus put/delete on the attachments bucket, all for plan seeding. They had no caller left. Removed — a net least-privilege gain, and there is now a test asserting the reconciler has no object grant at all, which is the stronger claim since S3 does not normalize keys.

Verification

  • All 8 slices CI-green, chain integrity confirmed (each slice's base is its parent's tip)
  • 3436 CDK / 681 CLI / 1436 agent tests at the tip
  • Zero decompose references in every slice individually, not just at the tip — an earlier pass looked clean at the tip while intermediate slices still carried them, which would have landed decompose text on main temporarily during the serial merge
  • Deployed to dev and live-verified by artifact: the deployed webhook bundle contains no plan surface; coding/decompose-v1 is now rejected at admission (Unknown workflow_ref, 400); a normal coding task still runs end to end (PR opened with the requested file, exact contents); coding/pr-review-v1 still admits; the reconciler's IAM shows no S3 statements

isadeks added 17 commits July 29, 2026 16:30
…load the PDF

Every PDF attachment was being refused with "Attachment '<name>' could not be
stored." The S3 upload threw "Cannot perform Construct on a detached ArrayBuffer",
and because attachment handling is fail-closed the whole task was rejected.

pdf-parse transfers ownership of whatever it is handed to its worker, which
DETACHES the underlying ArrayBuffer. It was handed
`new Uint8Array(content.buffer, content.byteOffset, content.byteLength)` — a VIEW,
which shares its backing store with the caller's Buffer. The caller uploads those
same bytes to S3 after screening returns, so by then its buffer was dead.

The existing comment reasoned about this and still got it wrong: it said "slice to
the exact PDF bytes so a pooled Buffer's backing ArrayBuffer isn't handed over
wholesale", which correctly avoids donating a whole pooled buffer but still hands
over the caller's. A view narrows WHAT is transferred, not WHOSE it is.

Now `Uint8Array.from(content)` — a copy. That costs one allocation of an
already-size-capped payload, against silently rejecting every PDF upload.

The unit tests could not catch this: they mock the PDFParse class, so no transfer
ever happens and a view behaves like a copy. The new test asserts the OWNERSHIP
boundary instead of the outcome — same bytes reach pdf-parse, different backing
store — which is checkable without a real worker. Verified by restoring the view
and watching it fail.
…straction

The dormant foundation for parent/sub-issue orchestration: pure graph logic,
the DynamoDB row store, and a surface-agnostic feedback layer. Nothing here is
instantiated in a stack yet — the executor and the stack wiring land later, so
this slice is synth-and-unit-test only and changes no deployed behaviour.

Graph core (pure, no I/O):
- orchestration-dag: validate a `depends_on` graph (duplicate id, dangling
  edge, cycle) and return its topological layering via Kahn's algorithm. The
  layers are what a reconciler releases children from, in dependency order.
- orchestration-graph-source: the seam between "where the graph came from" and
  the executor. Three tiers — a tracker that has native sub-issues (read it), a
  caller that supplies the DAG declaratively (CLI/API, or a planner), or a
  structureless trigger (single task).
- orchestration-integration-node: when a graph fans out to several leaves,
  append a synthetic node depending on all of them so there is one combined
  artifact instead of N unrelated PRs.
- orchestration-epic-tip: where a newly-added, dependency-less node stacks — the
  leaf frontier, so it inherits the epic's accumulated unmerged work rather than
  branching off the default branch.
- orchestration-base-branch: pick a child's base branch from its predecessors.

Row store and table:
- OrchestrationTable construct: orchestration_id (PK) + sub_issue_id (SK), with
  sparse GSIs to resolve a child back from its task id or its head branch.
- orchestration-store: the read/write surface over those rows, including the
  idempotency markers that keep duplicate webhook deliveries from double-acting.
- orchestration-log-events: structured events for a run, plus the scheduled
  sweep's backstop.
- linear-subissue-fetch: read a parent's children and their `blocks` relations
  into a DAG. Fails loud rather than silently truncating an over-size epic.

Channel abstraction:
- orchestration-channel: the surface-agnostic interface — required operations
  every channel must implement, optional ones gated by declared capability, so
  the engine never branches on which tracker it is talking to.
- orchestration-channel-{linear,jira,slack} + factory: per-surface adapters
  selected from the stored row.
- orchestration-comment-trigger: parse a mention comment into a command,
  ignoring the bot's own comments.
- iteration-reply: one maturing threaded reply per iteration rather than a
  stream of new comments; the two async writers of a reply converge instead of
  overwriting each other.
…it repo-wide

The test-scope lint relaxation switched off four rules across all 152 test files
to accommodate 13 new ones. `max-len` and `no-magic-numbers` are fair there —
literal fixtures and long expected strings are the point of a test. `no-shadow`
is not: it is correctness-adjacent in test code, where a shadowed `row` or `mock`
inside a nested describe is a common way to assert against the wrong fixture and
still pass.

So `no-shadow` stays on, and the two shadows it found are fixed rather than
silenced. One was a redundant local `makeDdb` identical to the file-level helper
(deleted). The other was a local `child` that builds a persisted ROW shadowing a
file-level `child` that builds a graph node — genuinely two different shapes
under one name, which is exactly the hazard. Renamed to say which it is.
…ontain

Review caught two inaccurate comments. Both were worth fixing, and looking for
more of the same class turned up two others.

The integration-node comment gave the synthetic id as `<orchestrationId>#integration`
while the code builds `__integration`. Not a harmless typo: the `#` form is exactly
what the comment thirty lines above warns will 400 the child, because the id flows
into an idempotency key validated against /^[a-zA-Z0-9_-]{1,128}$/. A reader
trusting the example would reach for the one separator that breaks it. Now points
at the suffix constant so the two cannot drift again.

Three comments referenced symbols that are not in this slice:

- `findOrchestrationIds` — real, but in the stranded-orchestration reconciler,
  which lands later. Replaced the cross-reference with the property it was there to
  convey: every paginated read of this table must follow LastEvaluatedKey, because a
  single page is a silent partial answer rather than an error.
- `discoverOrchestration` — the consumer lands with the compute plane; now described
  by role instead of by name.
- `renderFailureReply` — called "the existing" renderer when nothing of that name
  exists here yet, which is the most misleading form: it reads as a claim about the
  current tree. Now describes it as arriving with the activation slice.

A comment naming a symbol a reader cannot grep is a dead end, and in a stacked
series it is an easy defect to introduce, since the symbol does exist on the branch
the slice was carved from.
…ormant)

Turns "one plain issue" into a reviewable sub-issue graph, and makes a long
iteration legible while it runs. Like S5, none of this is wired into a stack
yet — no handler calls it and no construct is instantiated, so this slice is
unit-test-only and changes no deployed behaviour.

Auto-decomposition — plan, review, revise, then run:
- decomposition-mode: pure label parsing. The plain trigger label runs an issue
  as one task (or runs its existing sub-issue graph); `…:decompose` plans first
  and waits for approval; `…:auto` plans and starts immediately. A decompose
  suffix on an issue that already has sub-issues is a no-op.
- decomposition-planner: parse and validate the plan a `coding/decompose-v1`
  agent emits as an artifact. Budget is DERIVED from each piece's S/M/L size
  rather than asked of the model, so the Σ is a stable, explainable ceiling.
  Edges are indices into the plan's own list, validated as a DAG before anything
  is created.
- decomposition-caps: per-project limits. Over-cap REJECTS with a message and
  never trims — trimming can silently drop a node others depend on.
- decomposition-render: the proposal comment, and every note around it. Plain
  English throughout: no "critical path" or "cost ceiling" jargon, the spend
  figure is framed as the safety limit it is, and each decline says what actually
  happened rather than a plausible-sounding stand-in.
- decomposition-store: the pending plan survives between the propose webhook and
  the approve one. Create-once so a redelivery is a no-op; consume is a
  conditional delete-and-return so two racing approvals can't both write back.
- decomposition-writeback: create the real sub-issues and their `blockedBy`
  relations. Idempotent and resumable — a planned node whose title already
  exists is reused, so a retry after a partial write-back doesn't double-create.
- decomposition-flow: ties the above into caps → propose-or-seed, plus the
  approve/reject verdict path. All I/O injected, so the control flow is testable
  without Linear or DynamoDB.
- plan-commands / plan-revise / plan-revise-interpret: a reviewer can edit the
  plan directly ("drop 3", "merge 1 and 2") or in prose. Edits apply
  deterministically to the CURRENT plan and the "What changed" line is a COMPUTED
  before→after diff — never a model self-report, which used to let a dropped
  piece quietly reappear with a fabricated justification.

Iteration feedback:
- iteration-heartbeat (+ construct and scheduled sweep): a long iteration used to
  show "starting on this" and then nothing until it finished. The sweep edits the
  SAME maturing reply in place to show elapsed time and the latest progress note
  — no new comments. Eligibility keys on the reply-routing fields, so standalone
  iterations are covered too, not just orchestrated ones.
- iteration-reply-claim: claim a reply so two writers converge on one comment.
- failure-reply: failure is answerable. A red build points at the build log by
  task id (the agent runs the build itself, and the target repo may have no CI);
  an agent crash gives the classified reason, a truncated excerpt, and whether to
  retry or escalate. Never the raw build output — that's untrusted repo code.
- clarify-resume: resume a task that stopped to ask a question before spending.
… a dead regex

**A private commit sha was the whole justification for skipping a PROMPT_ATTACK
screen.** That is the one sentence a security reviewer needs to be able to
follow, and it pointed at a commit no public reader can resolve. Replaced with
the actual structural argument: the reviewer's instruction is embedded as
delimited data inside a prompt whose only job is to classify it into a fixed edit
vocabulary, the output is validated against that closed set before anything is
applied, so a jailbreak cannot widen what the caller does — and a note not to
reuse this shape where the output is executed rather than matched.

**The multi-part conjunction regex could not match its own intended phrasings.**
A trailing `\b` after the `plus,` and `;` alternatives requires a word character
to follow, which inverts the intent: "the form, plus, a signup page" scored zero
while "plus,a signup page" scored one, and "do a; b; c" scored zero while
"do a;b;c" scored two. So the correctly-punctuated prose this is meant to catch
was the case it missed, and both punctuation alternatives were dead weight. Word
alternatives keep their boundaries; the punctuation ones no longer require one.

**`latestProgressNote` was documented as a shipped capability but has no
producer** anywhere in the stack — the sweep never sets it and there is no
persisted attribute to read, so the heartbeat shows elapsed time only. The render
path is written and tested, so rather than delete it or half-wire it, the field
and the module/construct docs now say plainly that it is reserved and what
wiring it needs.

**`IterationHeartbeat` shipped without a construct test**, the one place in this
slice where source and test coverage parted company. Added, following the
repo's per-construct synth-assertion convention — including an assertion for the
least-privilege claim the construct's own comment makes but nothing checked.

Also: a cross-file line-number reference that pointed at a line which does not
exist until a later slice (named the function instead), and the last of the
private work-item shorthand.
…t overclaimed

Two findings from a second review pass on my own fixes.

**The conjunction regex now false-positived on pasted code.** Removing the
trailing `\b` fixed the punctuation alternatives, but a bare `;` with no boundary
matches EVERY semicolon — and an issue description written for a coding agent
routinely contains a snippet, a stack trace or CSS. Four realistic single-task bug
reports flipped to "multi-part" and would have nagged the user to decompose them.
That is a false positive in the direction that costs attention, and my previous
commit message argued for avoiding exactly that.

The bare `;` is dropped. It never earned its place in either form: with the
boundary it only matched unnatural no-space input, and without one it matched
everything. `plus,` keeps the boundaryless form and the word alternatives keep
theirs, which is enough for both real multi-part prose shapes; a numbered list is
caught by the separate list-item path. A test now pins that a code paste stays
single-task, which is what the previous test set never asserted.

**The guardrail comment asserted a safety property the code did not have** — which
is worse than the private commit sha it replaced, because a reviewer will trust
prose. Three claims failed: the instruction was interpolated raw, so reviewer text
containing the `"""` fence could close the data block and continue at prompt
level; the free-text fields were type-checked but unbounded; and the computed diff
is title-keyed, so an edit reusing an existing title reports as "modified" rather
than "added" and cannot be relied on to surface a malicious edit.

Rather than soften the prose, the first two are now true: the delimiter is
neutralized in reviewer text and the instruction and returned fields are bounded.
The comment names the real backstop — the downstream guardrail screen at task
creation — and says plainly that the diff is a reviewer-facing summary, not a
security control.

All three new guards are mutation-verified.
…decomposition

Decomposition stays on the development branch as experimental rather than landing
on main: it presupposes one way of working — an issue split into a sub-issue graph
with a human approval gate on the proposed plan — and main should not presuppose
that. The iteration work in this slice does not: it applies to any task that
opened a PR, however that task came to exist.

Removes the 20 decomposition files (planner, caps, flow, store, types, writeback,
render, the plan-command and plan-revise handlers, and their tests). What remains
is the 12 files this slice is now about: the iteration heartbeat and its sweep, the
reply claim, clarify-resume, and the failure reply.

Two helpers in the deleted label module were NOT decomposition and had to survive:
`DEFAULT_LABEL_FILTER`, which the project-mapping table treats as the default when
a project sets no `label_filter` and the rollup renders in operator-facing copy,
and `hasHelpLabel`, the one-time explainer label. They now live in
`trigger-label.ts`, named for what they are. `triggerLabelVariants` was NOT kept —
it existed to return the `:decompose` and `:auto` variants, so with those labels
gone it would only ever return the bare base.

Tests came across with the module, plus one pinning `DEFAULT_LABEL_FILTER`'s value
directly. That constant is load-bearing in a way its size hides: changing it
silently stops every project that never set a filter explicitly.

Slice size drops from 8538 lines to 1918. The PR title and description need to
change to match, since this is no longer a decomposition slice.
…, rollup

The engine that actually runs a sub-issue graph: it watches child tasks reach a
terminal state, releases whatever they unblocked, keeps the parent's status panel
current, and re-stacks dependents when a branch moves.

- orchestration-discovery: the composer that turns a trigger into a seeded run —
  read the graph, validate it, append the integration node if it fans out, then
  persist it and hand back the root set to start.
- orchestration-reconcile: pure gating. Given a child that just went terminal,
  decide which dependents are now releasable, which must be skipped, and when the
  parent itself is done. Also covers two recovery shapes: comment-fixing a failed
  child un-fails it and re-releases the dependents that were skipped behind it,
  and re-triggering a finished epic retries only its failed/skipped children.
- orchestration-release: create a child's task, with a claim so two concurrent
  releasers can't both launch it. A guardrail rejection or an un-onboarded repo is
  terminal with a reason the user can act on; a 5xx or a duplicate replay rolls
  back to ready so the sweep retries.
- orchestration-rollup: the parent's single maturing panel — one comment that
  matures in place rather than a stream of updates. A failed row carries an
  indented sub-line saying what failed and where to read it.
- orchestration-restack: when a child's branch changes, work out which dependents
  need their base moved.
- orchestration-parent-comment: route a comment on the epic to the sub-issue it's
  about, and say so plainly when it looks like new work instead of a change.
- orchestration-reconciler (handler + construct): the TaskTable-stream consumer
  that drives all of the above, with a DLQ for poison records, a filter so only
  terminal-status writes invoke it, and partial-batch reporting so one bad record
  can't re-drive its healthy siblings.
- reconcile-stranded-orchestrations (handler + construct): a scheduled backstop
  for a run whose stream event was lost.

Not dormant, unlike S5/S6 — read this part carefully:

TaskTable gains a LinearIssueIndex GSI and has its stream enabled (NEW_IMAGE).
Both are needed by this slice: the reconciler consumes that stream, and it queries
that index to total an issue's iteration cost. The GSI's projection is deliberately
narrow because a projection cannot be changed in place later. Enabling a stream and
adding a GSI are both in-place CloudFormation updates, so no table replacement.

The reconciler constructs themselves are NOT instantiated in any stack yet — the
wiring lands in the next slice — so nothing consumes the stream on deploy.
The epic panel's retry fallback told the user to remove and re-apply a
hardcoded label. The trigger label is per-project configurable through the
project mapping's `label_filter`, and the platform default is a different
string — so a user who followed the instruction re-applied a label the webhook
does not filter on, and nothing happened.

`renderEpicPanel` and `upsertEpicPanel` now take the resolved label and
interpolate it, falling back to the platform default when a caller has none.
Rendering the panel confirms the output; the existing test had asserted the
hardcoded string, which is why it never caught this.
… hint

The previous fix made `renderEpicPanel` take a `labelFilter` and interpolate it —
but no production caller passed one, so in practice every user still saw the
platform default and the hint was still wrong for any project that renamed its
trigger label. The fix was cosmetic.

The reconciler is where the panel is rendered, and it has no project id to look a
mapping up with — it works from the orchestration row. So the label is now
captured where the mapping IS in hand, at seed time, and persisted on the meta
row's release context; the reconciler reads it back and passes it to the panel.
Rows seeded before this field existed simply have no label and fall back to the
platform default, as before.

Only the settled-panel call site is wired, deliberately: the retry hint renders
only when the epic is terminal AND something failed, and the other two call sites
render an in-progress panel that has no hint.

Guarded in both directions — dropping the write fails the seed test, dropping the
read hydration fails the load test. The read half had no coverage until now, so
removing it left the whole suite green.
… the default

Review flagged trigger_label as only half-wired in this slice. Checking it, the
gap is wider than that: the field is read, persisted, hydrated, and threaded to
the panel, but NO code path on any branch ever assigns it a value — not this
slice, not the activation slice, not the branch this was carved from. The whole
plumbing was dead, so every epic's retry hint renders the default label
regardless of what the project is configured to trigger on.

That makes the hint actively misleading rather than merely generic: telling an
operator to re-apply `bgagent` when their project triggers on something else
sends them to a label that will not start anything.

The seed site is in this slice, so the fix belongs here. The decompose task
already carries its configuration through `channel_metadata` (mode, caps,
revision round), so the label rides along the same way and is populated onto the
release context at seed time.

A blank or whitespace value is treated as absent so the renderer's default
applies, rather than rendering an empty label in the hint — which also covers a
task stamped before this field existed.

The producer side (the webhook stamping `decompose_trigger_label` from the
project mapping's resolved `label_filter`) lands with the webhook, in the
activation slice. Until then this reads as absent and behaves exactly as before,
so the two halves are safe to land apart in this order — consumer first, and
never the reverse.

Verified by deleting the parse and watching the assertion fail.
…raph EXECUTION

With decomposition staying on the development branch, the reconciler no longer
needs to build a graph — a graph on main is human-authored and seeded by the
webhook. This removes the plan-consuming half and keeps the executing half.

Gone: the planning-task event shape and its parser, the plan reconciler, the
artifact fetch, the :auto seed path, and the seed-time attachment hydration. 656
contiguous lines, plus the six imports of the deleted planning modules and the
stream-handler branch that dispatched to them.

Kept, and worth naming because the file is now much smaller: terminal-child
reconciliation, dependent release, the restack cascade, panel refresh and settle,
failed-node recovery, and the iteration reply. Those are what the slice is for.

The removal also made three things dead that were only ever used by seeding — the
S3 client, the Bedrock screening config, and the artifacts-bucket env — so they
went too. Net effect on IAM is a least-privilege improvement: nothing in this
slice reads S3 any more.

Two consumers needed repointing rather than deleting, since they were never about
decomposition:

- `orchestration-rollup` imported DEFAULT_LABEL_FILTER from the deleted label
  module; it now takes it from `trigger-label`.
- A test asserted that a planning task falls through `parseTerminalTaskRecord`.
  The property it was really pinning is that the gate is the presence of
  orchestration_id, not the workflow id — a task outside a graph must not be
  mis-gated as a child and released against a graph it has nothing to do with.
  Rewritten around a plain terminal record so it still guards that.

Also drops a probe mock that existed because the seed path failed closed on an
attachment probe. With the seed gone the reconciler never probes, so the mock was
asserting nothing.
…integration node

The epic panel's preview came only from the synthetic integration node, so a chain
got no preview at all — the case where it is most obviously wanted, because the
reviewer is looking at the parent and a finished node holds exactly the artifact
they want.

An integration node exists only when a graph has SEVERAL leaves; it is there to
merge them. With one leaf there is nothing to merge: everything already converged
on the final node, so a synthetic node would re-run work that node just did and its
"combined" preview would duplicate the leaf's own. That gate is correct. What was
wrong is concluding "no integration node" means "no preview".

The panel now falls back to the sole leaf. Deliberately only the SOLE leaf: with
several leaves and no integration node (mid-flight, or an older row set) it still
shows nothing, because picking one of several would present a single branch's
result as the whole epic's — worse than showing nothing.

Leaf-finding reuses `computeLeaves`, the same function the seeder uses to decide
whether an integration node is needed, so the panel's idea of the final node cannot
drift from the seeder's.

The label is now conditional. "Combined preview" is only true when leaves were
actually merged; on a chain it reads "Preview". Calling a chain's final-node preview
"combined" tells a reviewer several branches were merged when none were. Both
variants were RENDERED and read as output, not reviewed in source.

Verified by mutation, three ways: reverting to integration-only, picking the first
child instead of the leaf, and hardcoding the "Combined" label each fail the test
and nothing else. Three existing panel tests changed expectations, which is the
honest signal that this alters user-visible output rather than only adding a path.
…n child

A released child was submitted with no workflow_ref, so it resolved to
default/agent-v1 — the repo-OPTIONAL generic workflow. Every other channel
(Linear, Jira, Slack) pins CODING_WORKFLOW_ID at its own call site precisely
because of this; create-task-core.ts even says so in a comment. The orchestration
release path was the one caller that never did.

The consequence is not a clean failure. default/agent-v1's setup skips the
stacked-base and predecessor-merge path entirely, so:

- A CHAIN survives by luck. Its child needs no merge, works from the base it was
  handed, and the result looks correct. That is why this went unnoticed.
- A DIAMOND does not. The integration node exists to merge two predecessor
  branches; it was handed both in channel_metadata, then given a clean default
  branch instead. The agent read a task description asking it to integrate, could
  not see either branch, and hand-wrote an approximation of ONE arm — silently
  dropping the other's work while producing a plausible-looking PR.

Found live: a two-leaf epic's integration PR contained only the first arm's change,
and the second arm's was absent from the merged file entirely. Everything upstream
was correct — the node was seeded with both dependencies, selectBaseBranch returned
shape 'diamond' with both branches, the task record carried both, and the
orchestrator's payload wiring was deployed. Only the workflow was wrong.

Two tests, one for a normal child and one for an integration node, asserting the
REQUEST body rather than the context — nothing had asserted the body before, which
is why the whole suite passed with the bug in place. Verified by deleting the pin
and watching both fail.
… comment

target the combined result

Two fixes for one incident. An epic fanned out an API child and a UI child in
parallel; each saw only its own sub-issue text and chose its own names — `kyoto`
with `checkIn`/`checkOut` on one side, `wander-kyoto` with `startDate`/`endDate`
on the other. Both passed their own tests, the branches merged with no conflict,
and the deployed flow was broken. Neither child was wrong in isolation. The
agreement existed only in the parent, which neither of them could see. Then the
follow-up comment about the failure landed on the UI child, where the backend code
was not present, so the agent adjusted how the error was displayed.

SHARED CONTEXT. The epic's title and body are captured at seed time onto the meta
row and prepended to every child's task description, delimited and labelled, with
an explicit statement that siblings are working in parallel from the same text and
that any name or shape it specifies is a contract rather than a suggestion. Seed
time is the only point where the issue body is in hand — the reconciler releases
from the stored row with no token to re-fetch it. Truncated at the single write
site (4000 chars/field) for two independent reasons that both bind: the 400 KB
DynamoDB item limit, and the fact that this text is guardrail-screened and counted
against every child's prompt budget. Truncation is marked, so a reader never
mistakes half a spec for the whole. The integration child gets it too — it is where
a cross-boundary mismatch actually surfaces, and without the contract it cannot
tell a real mismatch from two equally plausible conventions.

TARGETING THE COMBINED RESULT. `parseParentNodeReference` already looked for
`integration`/`combined`, but the keyword only ADMITTED the synthetic node and a
title word still had to match. So `integration` worked by accident (it appears in
the node's title) while `combined` — the word the panel itself shows, in "combined
result", "Combined PR", "Combined preview" — matched nothing and fell through to a
"which sub-issue?" reply. The node carries no Linear identifier, so that keyword is
the only handle a user has. The keyword is now the match.

The disambiguation reply also lists it, with the exact phrasing to type. It is
listed but never auto-selected: the combined branch holds every sibling's work, so
a misrouted change there is the most expensive kind to unpick, and an ambiguous
comment must still ask rather than guess. A test asserts the advertised keyword and
the accepted keyword are the same constant, because the failure mode of drift is a
reply that tells someone to type a word the parser ignores.

Verified by mutation: dropping the context, reverting the keyword to a gate, and
hiding the hint each fail their own test and nothing else. Note the pre-existing
keyword test passed against the gate behaviour — it asserted `integration` routes,
which it did, for the wrong reason.
…l admits the child

Caught on the first live run of the change: one child failed at admission with
"Task description was blocked by content policy". The shared-context preamble was
written as instructions to the agent — "use it exactly", "do not invent an
alternative" — and stacked with the epic's own imperatives the whole task
description trips the guardrail's PROMPT_ATTACK filter at MEDIUM confidence. The
child never ran.

That is a worse failure than the bug this feature fixes: the epic previously
produced two children with mismatched contracts, and this version produced one
child and one that could not start.

Isolated it against the deployed guardrail rather than guessing. Each piece passes
alone — the delimiters, the epic's own text, the child's own text, and every
individual sentence of the preamble. It is the accumulation of instruction-shaped
prose that trips it, and swapping only the preamble's voice flips the same assembled
description from GUARDRAIL_INTERVENED to NONE with the epic text unchanged.

Now descriptive: it says several sub-issues are being built in parallel, that each
was given this text, and that the names in it are shared with those siblings — so an
alternative chosen here would not match theirs. That carries the same constraint
without addressing the model in the second person, and a model acts on it just as
readily.

A test asserts the VOICE, not the wording: no "use it exactly", no "do not invent",
no "you must", while still requiring the parallel-siblings and shared-with framing.
The failure mode this guards is subtle — imperative phrasing reads as an improvement
and silently makes children unadmittable.
@isadeks
isadeks force-pushed the carve/s6-decompose-iteration branch from c5a6d3c to e48273b Compare July 29, 2026 15:32
@isadeks
isadeks force-pushed the carve/s7-orchestration-plane branch from 4415f8f to e8aa93c Compare July 29, 2026 15:33
Base automatically changed from carve/s6-decompose-iteration to carve/s1-foundation-contracts July 29, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants