feat(cli): bgagent linear remove-workspace + DELETE route + fail-closed resolver (#306) - #681
Conversation
… gaps Review follow-up (self-review agents on PR #681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
✅ Acceptance summary (for the reviewer)#306 —
🔀 Merge guidance (for the reviewer)Cluster 🤖 orchestrator note (agent) — promotion is orchestrator-driven; merge remains a human action. |
isadeks
left a comment
There was a problem hiding this comment.
Verdict — Request changes
The shape of this change is right and the security instincts are good (delegate writes to the API role, revoke-first fail-closed, admin-only, no secrets logged, idempotent secret delete). But the slug lookup that gates the entire endpoint is built on a DynamoDB Scan mis-use that makes the command 404 on live, active workspaces as soon as the registry holds more than one row — which is the normal steady state on this platform, and becomes guaranteed after the first soft-removal. The handler unit tests can't see it because the test double models Limit incorrectly.
Prior review state: none. The only existing comment is the author's own acceptance summary; there are no prior blocking claims to carry forward or retire.
Governance: backing issue #306 is approved — gate satisfied (ADR-003). Branch feat/issue-306-linear-remove-workspace matches the convention.
Vision alignment — passes
This is squarely on-tenet. Onboarding was one command and removal was undocumented DDB/Secrets-Manager surgery; closing that asymmetry is bounded blast radius work. Routing the destructive path through an authenticated endpoint so DDB + Secrets Manager grants stay on the Lambda role rather than on every CLI user's IAM identity is the correct call and matches the existing link precedent. Default soft-revoke preserving an audit row keeps outcomes inspectable. No tenet is traded, so no ADR is required.
Blocking issues
B1 — Limit: 1 on a filtered Scan 404s live workspaces (critical correctness)
cdk/src/handlers/linear-remove-workspace.ts:95-106
const scan = await ddb.send(new ScanCommand({
TableName: WORKSPACE_REGISTRY_TABLE,
FilterExpression: 'workspace_slug = :slug AND #status = :active',
...
Limit: 1,
}));
const row = scan.Items?.[0];
if (!row) { return errorResponse(404, ...); }In DynamoDB, Limit bounds the number of items evaluated, and FilterExpression is applied after that evaluation. Per the API reference: "Limit — the maximum number of items to evaluate (not necessarily the number of matching items)." So Limit: 1 examines exactly one arbitrary item, applies the filter to that one item, and returns Items: [] with a LastEvaluatedKey whenever the examined item isn't the target. The handler never follows LastEvaluatedKey.
Failure scenario: registry holds ws-other (slug other, active) and ws-acme (slug acme, active) — the documented shared-stack-per-org model, where one stack hosts multiple workspaces via add-workspace. DELETE /v1/linear/workspaces/acme evaluates ws-other, the filter rejects it, Items is empty, and the admin gets 404 Workspace 'acme' is not an active registration. for a workspace that is active and that they own. Retry is not a workaround — Scan ordering is stable for an unchanged table, so it 404s deterministically.
This is self-aggravating: the default soft-removal leaves status='revoked' rows in the table forever, so every successful removal adds another non-matching row and raises the probability that the next removal misses. Even a single-workspace deployment starts failing after its first revoke-then-re-add cycle.
It also silently defeats B1-adjacent intent: a duplicate slug across two workspaces can never be detected.
Fix — drop Limit and paginate to completion, matching the convention already established in this repo for exactly this table shape (cdk/src/handlers/jira-webhook-processor.ts:131-167 and cdk/src/handlers/shared/linear-issue-lookup.ts:100-115 both scan the registry without Limit and document the small-table assumption):
let row: Record<string, unknown> | undefined;
let lastKey: Record<string, unknown> | undefined;
do {
const page = await ddb.send(new ScanCommand({
TableName: WORKSPACE_REGISTRY_TABLE,
FilterExpression: 'workspace_slug = :slug AND #status = :active',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':slug': slug, ':active': 'active' },
ExclusiveStartKey: lastKey,
}));
row = page.Items?.[0];
lastKey = page.LastEvaluatedKey as Record<string, unknown> | undefined;
} while (!row && lastKey);Please add a regression test that seeds two active rows with the target second (see N1 — the current double cannot express this).
B2 — mapping cleanup is a provable no-op, but is reported to the operator as success
cdk/src/handlers/linear-remove-workspace.ts:287-307, surfaced at cli/src/commands/linear.ts:1301
deleteWorkspaceProjectMappings filters on linear_workspace_id. I traced every writer of LinearProjectMappingTable: the only one is cli/src/commands/linear.ts:1484-1493 (onboard-project), which writes linear_project_id, repo, label_filter, optional team_id, status, onboarded_at, updated_at — no workspace identifier of any kind. The webhook processor only reads that table (linear-webhook-processor.ts:163-164). So the filter matches zero rows on every real deployment, today and for all pre-existing rows.
I appreciate that you disclosed this in the PR body and the guide. The problem is what ships alongside it:
- The operator is told it worked. The CLI unconditionally prints
✓ 0 project mapping(s) removed— a checkmark and a count, which reads as "cleanup ran, the table was clean." It is indistinguishable from a genuinely clean teardown, so the operator will not go do the manual cleanup the guide tells them about. That is a silent failure dressed as success. - Issue #306's acceptance criterion is not met. The AC asks for deletion of
LinearProjectMappingTablerows for the workspace; no such row is reachable. - It carries real cost for zero effect. Every default removal runs a full-table paginated
Scanof the mapping table, and it is this dead path that justifies both theprojectMappingTable.grantReadWriteDatagrant (linear-integration.ts:330) and the 10x timeout bump.
Cheapest honest fix: drop the mapping-cleanup path (and its grant, its --keep-mappings flag, and the timeout bump) from this PR, and file the schema follow-up. That leaves a smaller, fully-working command. If you prefer to keep the code in place for the follow-up, then at minimum make the output truthful — do not emit ✓ with a count when the match is structurally impossible; say explicitly that project mappings could not be attributed and must be removed by project id.
B3 — --purge + secret-delete failure leaks an OAuth credential with no durable record, and two comments describe a recovery path that doesn't exist
cdk/src/handlers/linear-remove-workspace.ts:255-266
markSecretDeletionFailed early-returns when purged is true. So on the --purge path: the registry row is deleted, the Secrets Manager delete then fails with e.g. AccessDeniedException, and the live OAuth secret is left in the account with no durable marker anywhere — the only trace is a CloudWatch log line. That is the exact leaked-credential condition the follow-up commit set out to make discoverable, still fully open on the flag most likely to be used for a hard teardown.
The doc comment at :249-251 states the marker works "even after --purge fails at the secret step, because the delete happens after the row write." That is backwards: with --purge the row write is a delete, which is precisely why the function skips. The comment asserts the opposite of the code directly beneath it.
Relatedly, :283-285 says recovery is "--keep-mappings + manual cleanup." Retrying with --keep-mappings still hits the status='active' scan, still misses the now-revoked row, and still 404s. The documented recovery path cannot work, on any flag combination. After any partial teardown this endpoint has no re-attempt path at all.
Fix — make --purge revoke-then-delete-row so both fail-closed and the marker hold:
Update(status='revoked') // fail-closed immediately, row still present
DeleteSecret // on failure: marker write now succeeds, row survives
Delete(row) // only once the secret is confirmed gone (purge only)
And please correct both comments to describe what the code actually does. A --force escape hatch that accepts a non-active row for re-attempt would close the retry dead-end, but a follow-up issue is fine for that.
Non-blocking suggestions / nits
N1 — the handler test double models Limit incorrectly, which is why B1 is invisible. cdk/test/handlers/linear-remove-workspace.test.ts:87-104: routeDdb returns the seeded row for any registry Scan, ignoring Limit and ExclusiveStartKey entirely. Ten tests pass against behavior real DynamoDB does not have (AI001 — integration verified only against a self-written mock). Worth noting that the test at :274 does correctly model LastEvaluatedKey for the mapping scan, so the fixture already knows how; the registry router just doesn't use it. Teach the router to slice by Limit and honor ExclusiveStartKey and B1 fails immediately.
N2 — two construct assertions are vacuous. cdk/test/constructs/linear-integration.test.ts:85-94 asserts some Lambda has both LINEAR_WORKSPACE_REGISTRY_TABLE_NAME and LINEAR_PROJECT_MAPPING_TABLE_NAME — but webhookProcessorFn already carries both (linear-integration.ts:208-212), so this passes with the new function deleted. :96-107 asserts some IAM policy allows secretsmanager:DeleteSecret without pinning it to the remove-workspace role. Pin both by logical id, or match the bgagent-linear-oauth-* resource pattern so the grant scope is actually under test. The DELETE-method test at :78 likewise doesn't pin the path.
N3 — the timeout comment is still inaccurate. cdk/src/constructs/linear-integration.ts:44-47 says 30s "vs. the 3s Lambda default the link/webhook request handlers use." Those handlers don't use the default — webhookFn and linkFn are both explicitly Duration.seconds(10) (:264, :303). The honest comparison is 30s vs. their explicit 10s. Flagging because the third commit (docs(#306): clarify remove-workspace Lambda timeout rationale) existed specifically to fix this sentence. Note that if B2 is resolved by dropping mapping cleanup, the entire 30s rationale goes away and 10s matches its siblings.
N4 — document the registry-Scan scale assumption. Once Limit is removed (B1), each removal scans the whole registry. That's correct and cheap at expected scale, but jira-webhook-processor.ts:132-136 sets the house style of saying so out loud ("expected to stay small (tens of rows) ... if this table ever grows large, add a GSI on status and Query it"). A GSI on workspace_slug would turn this into a Query and is the right long-term shape given the same slug→row lookup already recurs across linear-issue-lookup.ts.
N5 — deferred, agreed. BatchWriteCommand for mapping deletes and treating SM InvalidRequestException ("already scheduled for deletion") as idempotent are both reasonable follow-ups, not needed here.
Documentation
docs/guides/LINEAR_SETUP_GUIDE.mdrestructure is a genuine improvement: leads with the command, keeps the manual path as an explicit fallback, and the### Deactivating a single project mapping/### Manual fallbacksplit is clearer than what it replaced. The mapping caveat is called out honestly.- Starlight mirror verified in sync — I diffed the changed section of
docs/src/content/docs/using/Linear-setup-guide.mdagainst the source; identical. No hand-editing of the generated tree. CI's mutation guard will be clean. - One doc correction needed if B2 is fixed by dropping the path, and the caveat note should say cleanup matches nothing currently written rather than implying only legacy rows are affected.
Tests & CI
All checks green. Coverage is broad in shape — 401/400/404/403, purge vs. revoke, idempotent secret-gone, SECRET_DELETE_FAILED + marker, paginated mapping cleanup, missing oauth_secret_arn, plus the CLI prompt abort/proceed branch and api-client query-string mapping. The adversarial resolver test is the best thing in the diff: asserting smSend was never called pins the fail-closed property to short-circuit-before-read rather than to the return value, with an active control case to prove the test isn't vacuous. More of that, please.
The gap is depth, not breadth: the two highest-risk behaviors — multi-row registry lookup (B1) and --purge partial teardown (B3) — are the two the suite doesn't reach.
Bootstrap synth-coverage: not applicable, verified. No new CloudFormation resource types are introduced — AWS::Lambda::Function, AWS::ApiGateway::Resource/Method, and AWS::IAM::Policy are all already emitted by this construct. secretsmanager:DeleteSecret is a runtime grant on the function role, not a CFN-execution-role action, and cdk/src/bootstrap/policies/application.ts:241 already carries it regardless. No BOOTSTRAP_VERSION bump or artifact regeneration is owed. Adding LinearRemoveWorkspaceResponse to CLI_ONLY_ALLOWLIST is correct and consistent with the sibling LinearLinkResponse — it's a client-side envelope and the handler builds the body inline.
No CDK synth-performance concern: the construct suite keeps its single new App() + Template.fromStack() in beforeAll and does not re-enable bundling (#366 respected).
Review agents run
Ran, as rubrics applied over the full diff:
- code-reviewer — style/guidelines; found N2, N3.
- silent-failure-hunter — the highest-yield pass here; found B2 (success-shaped report over a structurally impossible match) and B3 (leaked credential with no durable record + a catch-adjacent recovery path that doesn't exist). Confirmed the
ResourceNotFoundException-only narrowing is correctly specific and that the non-idempotent branch genuinely rethrows rather than swallowing. - pr-test-analyzer — found N1 and the B1/B3 depth gaps; confirmed the resolver test is non-vacuous.
- comment-analyzer — found the
:249-251comment inverting its own code and the:283-285unreachable recovery claim (both folded into B3), plus N3. - type-design-analyzer —
LinearRemoveWorkspaceResponseis well-formed:readonlythroughout,statusa closed'revoked' | 'purged'union rather thanstring, andsecret_deleted/mappings_removedmake the partial-outcome states representable. No findings. - security-review — IAM, secrets, input validation. The
bgagent-linear-oauth-*prefix grant is correctly justified (name unknowable at synth) and matches the existing webhook-Lambda pattern; the nag suppression reason is specific and honest, not boilerplate.DeleteSecretis granted alone — noGetSecretValue, so this role cannot read tokens. Slug regex is anchored and applied before any AWS call. Logs carry slug/workspace-id/booleans only. 404-collapse to avoid a revoke-oracle is a nice touch. Its one finding is B3. - code-simplifier — omitted; B2's resolution is itself the simplification (delete the dead path), so a separate pass would be redundant.
Human heuristics
- Proportionality — pass. 316-line handler for a three-step teardown is honest, most of it comment and error handling. Flat file placement matches the existing
linear-*.tssiblings; no new abstraction invented for a one-off. The one disproportion is B2: a paginated scan + write grant + timeout bump sustaining a path that cannot match a row. - Coherence — concern. Terminology splits: the registry keys on
linear_workspace_id, the CLI argument and route parameter areslug, and the row field isworkspace_slug— reasonable given Linear's ownurlKey, but the mapping table now gets a fourth name for the same concept with no writer (B2). Otherwise the parallel structure withadd-workspace/update-webhook-secretis real: sharedSLUG_RE, sharedlinearOauthSecretName, sharedpromptLine, same delegate-to-API stance aslink. Reuse, not copy-paste. - Clarity — concern. Names are good and the comments explain why at an unusually high standard. But three of them are wrong in the same region (N3, and the two in B3), and comments this confident are load-bearing — a future maintainer will trust
:249-251and conclude the--purgeorphan is recorded when it isn't.✓ 0 project mapping(s) removed(B2) is the clarity failure with operational teeth. - Appropriateness — concern. This is AI001/AI005: DynamoDB
Limit-with-FilterExpressionis exactly the integration semantic a self-written double will get wrong, and here it did, so ten green tests assert what the mock does rather than what DynamoDB does (B1, N1). The fix is small and the surrounding structure is maintainable; the suite just needs to model the seam faithfully. Worth noting the correct pagination model already exists in this very file's mapping-scan test — applying it to the registry router is the whole change.
To be clear about where this lands: B1 is a mechanical fix, B3 is a small reordering plus two comment corrections, and B2 is most cheaply resolved by deleting code. The security posture, the delegation design, and the adversarial resolver test are all things I'd like to see more of. Happy to re-review promptly.
On merge sequencing — noted that this shares cli/src/commands/linear.ts, cdk/src/constructs/linear-integration.ts, and linear-oauth-resolver.ts with #345 (cluster ua-broad), and edits here are additive/localized. Rebase after #345 should stay mechanical.
… gaps Review follow-up (self-review agents on PR #681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
9491f0e to
29a6eeb
Compare
|
🤖 @scottschreckengaust (agent:w7) — signing out, over and out. |
isadeks
left a comment
There was a problem hiding this comment.
Verdict — Approve
All three blockers are genuinely fixed, and I verified each one by literally reverting the fix in the worktree and watching the new tests fail — not by reading the fix commits. That mattered here, because my prior review found the original bug was invisible to the test suite. It is not invisible any more.
| Prior finding | Adjudication |
|---|---|
B1 — Limit: 1 on a filtered Scan 404s live workspaces |
✅ FIXED (mutation-proven) |
| B2 — mapping cleanup is a no-op reported as success | ✅ FIXED (removed; deletion verified complete) |
B3 — --purge skips the orphan marker, leaks a credential with no record |
✅ FIXED (mutation-proven) |
N1 — routeDdb models Limit incorrectly (why B1 was invisible) |
✅ FIXED (mutation-proven) |
| N2 — vacuous construct env test (passes with the Lambda deleted) | ✅ FIXED (mutation-proven) |
| N3 — IAM assertion matches only some policy | 🟡 Fixed on the part that matters, over-claimed on the rest — see inline (nit) |
| N4 — inaccurate Lambda-timeout comment | ✅ FIXED |
No surviving blockers, no new blockers. Approving.
What I verified, concretely
I ran the suites against a matched toolchain and then mutated the source. Baseline: cdk 16/16 handler + 12/12 construct + 16/16 resolver (87 across the 6 Linear suites), cli 44/44.
B1 — FIXED. linear-remove-workspace.ts:109-121 drops Limit and paginates with do { … } while (!row && scanKey), matching the convention I cited (jira-webhook-processor.ts:132-155, shared/linear-issue-lookup.ts:101-107 — I re-read both; the comment's reference is accurate). Tracing my original failing input: registry holds ws-other (slug other, active) then ws-acme (slug acme, active); DELETE …/acme now examines page 1, matches nothing, follows LastEvaluatedKey, matches ws-acme on page 2, and the revoke lands on linear_workspace_id: ws-acme. The loop terminates on either a match or a null LastEvaluatedKey, so no infinite loop and no dropped last page.
Mutation: I restored Limit: 1 + scan.Items?.[0] verbatim. 2 tests failed — B1 regression: finds a live workspace that is not the first registry row and registry scan follows LastEvaluatedKey across pages. That is exactly the coverage that was missing.
N1 — FIXED, and it is what makes B1 observable. routeDdb (linear-remove-workspace.test.ts:105-121) now slices by Limit before applying the filter, advances on ExclusiveStartKey, and returns LastEvaluatedKey whenever unexamined rows remain — including when the page matched nothing (:114-117). That last detail is the one real DynamoDB behavior the old double lacked, and it is the reason the mutation above fails instead of passing.
B3 — FIXED. The ordering is now Update(status='revoked') (:160-166, unconditional) → DeleteSecret (:175-181) → Delete(row) only on --purge and only after the secret is confirmed gone (:228-234). markSecretDeletionFailed lost its purged special-case, so the marker lands on every flag combination. Both comments I flagged as backwards now describe the code beneath them accurately, and the false "recovery is --keep-mappings + manual cleanup" claim went away with the function that carried it.
Mutation: I restored delete-up-front-on-purge plus the if (purged) return; skip. 3 tests failed, including B3 regression: --purge + secret-delete failure keeps the row and marks it. The credential-leak-with-no-record condition is now pinned.
I also walked the new failure window the reorder introduces: if the final --purge Delete throws, the row survives as revoked and the secret is already gone. Fail-closed still holds and the outcome degrades to a soft removal — strictly better than the old ordering. Not a defect.
B2 — FIXED, and the deletion is complete. I grepped the whole repo for keep_mappings|keepMappings|mappings_removed|keep-mappings|deleteWorkspaceProjectMappings: the only surviving hit is the negative assertion at linear-remove-workspace.test.ts:228. Also gone: the LINEAR_PROJECT_MAPPING_TABLE_NAME env, the projectMappingTable.grantReadWriteData grant, the PROJECT_MAPPING_TABLE module constant, the mapping_cleanup phase, mappings_removed from cli/src/types.ts:492-497 (which still matches the handler's response shape exactly), and the --keep-mappings CLI flag + keep_mappings query param in api-client.ts:510-520. No orphaned caller, type, doc, or test. The user-visible promise is now kept rather than quietly broken — the CLI prints Project→repo mappings left in place — remove by project id if needed, and LINEAR_SETUP_GUIDE.md:226 says the same. Follow-up #687 is filed with the schema fix and an explicit list of what to restore. Thank you for taking the honest option rather than the cosmetic one.
N2 — FIXED. findRemoveWorkspaceFn() resolves the function via its role, and the env test asserts the registry var is present and the mapping var is absent. Mutation: re-adding LINEAR_PROJECT_MAPPING_TABLE_NAME fails it. Deleting the whole RemoveWorkspaceFn + route fails all 12. No longer vacuous.
N4 — FIXED. REMOVE_WORKSPACE_TIMEOUT_SECONDS = 10 and the comment now says it matches the sibling link/webhook handlers. I checked: linear-integration.ts:264 and :303 are both Duration.seconds(10). The comparison is finally accurate.
Also verified: the DELETE-method test is pinned to the {slug} resource — moving the method onto /workspaces fails it. The resolver adversarial test asserts a revoked slug returns null and smSend is never called, with an active-control using the identical valid token; both hold. Bootstrap: no new CFN types (ApiGateway Resource/Method, Lambda::Function, IAM::Policy are all already in resource-action-map.ts), and test/bootstrap/synth-coverage passes — no bundle bump needed. Docs mirror docs/src/content/docs/using/Linear-setup-guide.md is byte-identical to the source for the changed section. LinearRemoveWorkspaceResponse is correctly in CLI_ONLY_ALLOWLIST. Construct test synthesizes once in beforeAll and never re-enables bundling (#366 clean). ESLint clean on all four changed files.
Vision alignment — passes
Unchanged from my prior read, and the B2 resolution strengthens it: removal is now a bounded operation that does exactly what it claims. Delegating the destructive writes to the API role keeps blast radius off every CLI user's IAM identity, the default soft-revoke keeps the outcome inspectable, and the revoke-first reorder means fail-closed is now the first thing that happens on every path including --purge. No tenet traded; no ADR required.
Nits (non-blocking — none of these should hold the merge)
Four are inline. One more here:
Stale PR body. The summary half still describes the surface you removed: "Flags: --purge, --keep-mappings, --yes", "Optional project-mapping cleanup keyed on linear_workspace_id", "10/10 pass", and a Testing section that credits --keep-mappings forwarding and a "mapping-removal count reported". The later Supersedes note corrects some of it, but a reader of the summary gets a false picture of what ships — and on a squash-merge this body is the durable record. Worth a quick edit before merge.
The --force retry escape hatch has no tracking issue. Your reply says it "remains a follow-up," and I agreed a follow-up was fine — but I searched open issues and only #687 (the mapping schema) exists. Recovery is genuinely available today (marker on the row + ARN in the error log + the guide's manual delete-secret fallback), so this is not a hole, just untracked intent.
Documentation
Guide section rewritten to lead with the command, the --purge description correctly states the revoke-first-then-hard-delete ordering, and the mapping caveat is stated as a limitation rather than buried. Mirror regenerated and in sync (verified by diff, not by trusting the hook). #306's mapping-deletion AC is explicitly deferred to #687 with the rationale recorded on the issue. Only gap is the SECRET_DELETE_FAILED runbook note flagged inline.
Tests & CI
16 handler / 12 construct / 16 resolver / 44 CLI, all green; 87 across the 6 Linear suites. Bootstrap synth-coverage passes and no bundle update is required (no new CFN resource types). All 9 GitHub checks SUCCESS. The four coverage gaps I flagged are closed with tests that fail when the fix is reverted — I checked each one rather than taking the claim.
Review agents run
I have to be straight about this one: no agent-spawn tool was reachable in this review context, so I could not fan the pr-review-toolkit agents out as subagents. Rather than skip the step, I executed each in-scope agent's checklist directly against the diff and backed it with something stronger than any of them would have produced on its own — literal mutation testing in the worktree (6 mutations across the handler and construct, each run and each result recorded above).
- code-reviewer — ran by hand: routing (
cdk/handler + route,cli/command — matches AGENTS.md), L2 constructs, no hardcoded ARNs (Stack.of(this).formatArn), ESLint clean,tscclean on the changed files. - silent-failure-hunter — ran by hand; this was the core of B2/B3. The
ResourceNotFoundExceptionswallow is correctly narrow (name-checked, all other SM errors rethrown with a distinct code), the marker write is best-effort with its own logged catch, and the success-badge-over-a-no-op path is gone entirely. - pr-test-analyzer — ran by hand and mutation-verified, which is the only way the prior review's blind spot would have shown up.
- comment-analyzer — ran by hand; three comment inaccuracies I flagged last round are fixed, two minor ones remain as inline nits.
- type-design-analyzer —
LinearRemoveWorkspaceResponseis the only new type; shape matches the handler, allowlisted correctly,mappings_removeddropped from both sides in step. - security-review — ran by hand (IAM + secrets change): grant scoped to
bgagent-linear-oauth-*, admin-only viainstalled_by_platform_user_id, 404 collapses missing/revoked so the endpoint is not an existence oracle, no secret values logged (slug / workspace id / ARN / booleans only), fail-closed is now the first write on every path.
Human heuristics
- Proportionality — pass, and improved. The response to B2 was to delete the speculative path rather than defend it; the handler is now 288 lines doing one thing.
- Coherence — pass. The pagination follows the two existing registry-scan sites instead of inventing a third shape.
- Clarity — pass.
phasebreadcrumbs plus the durable marker mean an on-call engineer can answer "which secret leaked?" from one log line. AI004 concern from last round is resolved. - Appropriateness — pass on the dimension that failed last time. The handler double now models real DynamoDB filtered-Scan semantics rather than self-agreeing behavior (AI001), and the tests assert what the code should do — proven by watching them fail against the reverted code (AI005).
|
🤖 @scottschreckengaust (agent:w7) — 4 approval nits addressed in commit |
… gaps Review follow-up (self-review agents on PR #681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
d84ea37 to
b9d7d55
Compare
… gaps Review follow-up (self-review agents on PR #681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
5bbaf7d to
a19f16d
Compare
isadeks
left a comment
There was a problem hiding this comment.
Re-review at a19f16d1. Approve — my four approval nits from the July round were addressed in d84ea37f (incl. the non-tautological role-binding assertion), CI is green (8/8), mergeable. LGTM.
…ed resolver (#306) Add a `bgagent linear remove-workspace <slug>` command that deregisters a Linear workspace, replacing the manual DDB + Secrets Manager surgery that removal previously required. CLI (cli/src/commands/linear.ts): new subcommand mirroring add-workspace / update-webhook-secret UX — slug validation + a "type the slug to confirm" prompt (skipped by --yes). Delegates all writes to the backend via a new DELETE call so DDB/Secrets Manager grants stay on the API role, not on every CLI user (same pattern as `link`). Flags: --purge, --keep-mappings, --yes. Backend: new flat handler cdk/src/handlers/linear-remove-workspace.ts behind DELETE /v1/linear/workspaces/{slug} (route + Lambda wired in cdk/src/constructs/linear-integration.ts). Cognito-authenticated, admin-only (caller must match the recorded installed_by_platform_user_id). Default is a SOFT removal: flip the registry row to status=revoked (audit trail preserved) and delete the bgagent-linear-oauth-<slug> secret; --purge deletes the row outright. Secret deletion is idempotent (ResourceNotFoundException swallowed, other SM errors rethrown). Optional project-mapping cleanup keyed on linear_workspace_id. Fail-closed resolver: the OAuth resolver already rejects any non-active registry status (cdk/src/handlers/shared/linear-oauth-resolver.ts) — so a revoked workspace can no longer resolve a token or route webhooks the instant this returns. Added an adversarial test proving a revoked slug is rejected WITHOUT ever reading the secret, plus an active-control case. Docs: rewrote the "Removing a workspace" section of the Linear setup guide (Starlight mirror regenerated) to lead with the command and keep the manual DDB steps as a fallback. Security: no secrets logged (slug/workspace_id/booleans only); reuses existing AWS SDK clients; no new dependencies. SAST clean on new files. Closes #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… gaps Review follow-up (self-review agents on PR #681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…e revoke-first + drop dead mapping cleanup
B1 (blocking): the registry lookup used `Limit: 1` on a FILTERED Scan.
DynamoDB applies FilterExpression after evaluating `Limit` items, so a
single-row limit examined one arbitrary row, filtered it out, and 404'd a
live workspace whenever the registry held >1 row (the normal shared-stack
state; guaranteed after the first soft-revoke). Drop `Limit` and paginate to
completion, matching jira-webhook-processor.ts / shared/linear-issue-lookup.ts;
document the small-table assumption. Fix the test double so the registry-scan
router honors Limit/ExclusiveStartKey and applies the filter to the examined
slice (this is why B1 was invisible), and add a two-active-row regression with
the target on the second page (fails before, passes after) plus a
follow-LastEvaluatedKey assertion.
B2 (blocking): mapping cleanup was a provable no-op reported as success —
LinearProjectMappingTable rows carry no workspace id (onboard-project writes
none), so the `linear_workspace_id` filter matched zero rows always while the
CLI printed "✓ 0 project mapping(s) removed". Take the reviewer's cheapest
honest fix: remove the mapping-cleanup path entirely — deleteWorkspaceProjectMappings
+ its call, the `--keep-mappings` flag, the projectMappingTable.grantReadWriteData
grant, the LINEAR_PROJECT_MAPPING_TABLE_NAME env, and the 30s timeout bump
(reverted to 10s matching siblings — N3). CLI output + LINEAR_SETUP_GUIDE no
longer claim mapping cleanup; mappings are removed by project id. Schema
follow-up (record linear_workspace_id at onboard time) to be filed separately.
B3 (blocking): on `--purge`, markSecretDeletionFailed early-returned, so a
failed DeleteSecret after the row was deleted leaked the OAuth secret with no
durable record. Reorder to revoke(Update)→DeleteSecret→delete-row(purge only,
after secret confirmed gone), so the marker always lands and fail-closed holds.
Drop the `purged` special-case; correct the two backwards comments (:249-251,
:283-285). Add a --purge marker regression + an ordering assertion.
N2: pin the previously-vacuous construct tests — resolve RemoveWorkspaceFn via
its unique DeleteSecret role grant, pin the DELETE method to the {slug}
resource, and assert the DeleteSecret grant is bound to that role AND scoped to
bgagent-linear-oauth-*.
Closes #306
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…st comments + runbook markers (#306) Addresses the 4 non-blocking approval nits from @isadeks: 1. Role-binding assertion (cdk/test/constructs/linear-integration.test.ts): findRemoveWorkspaceFn() now derives the remove-workspace role INDEPENDENTLY from the FUNCTION resource (its registry-only Environment signature + Role Fn::GetAtt), not from the DeleteSecret policy. The secret-prefix test asserts the DeleteSecret grant lands on THAT role, so mis-wiring the grant onto another role now fails the test (proven: moving the grant to linkFn makes `expect(roleRefs).toContain(role)` fail). No longer a tautology. 2. Pagination comment (cdk/src/constructs/linear-integration.ts): the bounded sequence now names the lookup phase (registry lookup → revoke → secret delete → optional row purge) and states the lookup scan is the only paginating phase, bounded by the registry's tens-of-rows scale. 3. Test prose (cdk/test/handlers/linear-remove-workspace.test.ts): :270 comment now says "Page 1: empty (no matching row) + a continuation key" to match `Items: []`, and the :262-265 preamble no longer describes stale routeDdb Limit behavior. Comment-only; test logic unchanged. 4. Runbook markers (docs/guides/LINEAR_SETUP_GUIDE.md + regenerated Starlight mirror): the manual-fallback section now names the durable markers (secret_deletion_failed / secret_deletion_error / orphaned_oauth_secret_arn) and the SECRET_DELETE_FAILED error code, pointing operators to the delete-secret fallback. Closes #306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…base Rebasing onto main (12c9b63) surfaced three collisions with work that merged after this branch was authored. None were textual conflicts — git merged the affected files cleanly and produced a build that failed. 1. `allowTestInvoke: false` on the DELETE integration. #854 stripped the API Gateway console test-invoke Lambda permissions to reclaim CloudFormation resources under the 500-resource ceiling, and added a guard asserting none are emitted. This route predates that convention, so it re-introduced one and failed the guard 6x (once per compute_type x enableToolGateway variant). The two sibling routes in this same construct already pass the option; now all three agree. 2. Attributed-Lambda count 46 -> 47. The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main independently reached 46, so `git rebase` dropped the commit as "patch contents already upstream" — identical text, different reason. The textual change survived; the intent did not. With this branch's extra Lambda the correct value is 47. 3. Solution user agent on the new handler's SDK clients. linear-remove-workspace.ts constructed `new DynamoDBClient({})` and `new SecretsManagerClient({})` directly, which drops the solution user agent (#319). Now built through makeDocClient()/makeClient(), matching linear-link.ts and linear-webhook.ts. Also resolved the LINEAR_SETUP_GUIDE.md conflict from #831: kept its new "Vault-managed workspaces" section and dropped the duplicated webhook/ uninstall sentence, which this branch had already relocated into the parent "Removing a workspace" section. Starlight mirror regenerated. Refs #306
ae15437 to
4b5ae69
Compare
isadeks
left a comment
There was a problem hiding this comment.
1. Verdict
Request changes — one blocker, and it is small. To be clear about where this stands: all three blockers and all four nits from my July round are genuinely fixed — I verified each against current code rather than trusting the commit messages, and mutation-tested the two that were previously invisible to the suite (details in §6). My 31 Aug review was an approve, and nothing in the subsequent rebase regressed it.
The one blocker is not a re-litigation and not something I previously waived. It is a new cross-feature interaction: AgentCore Identity vault onboarding landed on main at 12c9b63f on 4 Sep — after that approval — and reached this branch via the rebase. Neither change is wrong alone; together they leave one teardown path reporting success while a live credential survives. Three independent review passes converged on it from different angles, which is why I am blocking rather than filing it as a follow-up.
2. Vision alignment
Squarely bounded blast radius: routing removal through an API so the DynamoDB and Secrets Manager grants live on one Lambda role instead of on every CLI operator's credentials is exactly the right shape, and the inline rationale at linear-integration.ts:490-496 says so well. The revoke-first ordering makes teardown fail-closed, and the secret_deletion_failed / orphaned_oauth_secret_arn marker keeps a partial teardown reviewable — an operator can always find the orphan. No tenet traded, no ADR needed.
The blocker is the one place the tenet does not hold: on the vault path the blast radius is not actually bounded by the command that claims to bound it.
3. Blocking issue
B1 — "Removed" is reported while a live, self-refreshing Linear grant survives
cdk/src/handlers/linear-remove-workspace.ts:159-186, surfaced at cli/src/commands/linear.ts:513-514, and docs/guides/LINEAR_SETUP_GUIDE.md:310-333
The handler deletes only row.oauth_secret_arn. It never reads row.provider_name. A workspace onboarded through the Identity vault carries provider_name + vault_user_id (written at cli/src/commands/linear.ts:1349-1350) and an AgentCore OAuth2 credential provider created outside CloudFormation, holding the Linear client secret and a live, self-refreshing grant. That provider is untouched, unmentioned, and unreported.
What the operator sees on that path: secret_deleted: false → • OAuth secret was already absent (nothing to delete). That string is byte-identical to a genuinely clean teardown. The new docs section lists exactly two effects and no vault caveat — even though the existing "Vault-managed workspaces: the credential provider outlives the stack" warning sits about 30 lines below it (LINEAR_SETUP_GUIDE.md:364-381). So the one operator who most needs the follow-up step is the one told there is nothing to do.
The same conflation has a second, narrower manifestation: when a registry row simply lacks oauth_secret_arn (a setup that created the secret but died before writing the row completely), :175 skips DeleteSecret entirely and reports the same "already absent". The secret name is deterministic — bgagent-linear-oauth-<slug> (cli/src/linear-oauth.ts:117-119) — and the IAM grant already covers that prefix, so the delete is attemptable. cdk/test/handlers/linear-remove-workspace.test.ts:393 currently encodes the skip as expected behaviour, which is why neither case is visible.
Root cause is one type. secret_deleted: boolean (cli/src/types.ts:641) collapses three distinct facts — deleted, already gone, and never existed because this workspace is vault-managed — into a single false. That is the same absent-vs-empty conflation the rest of this PR is careful about.
Fix (all small, and the type change closes both manifestations):
- Narrow to
secret: 'deleted' | 'absent' | 'not_applicable', and returnprovider_name(orvault_managed: true) in the response. - When
provider_nameis present, have the CLI print the follow-up:aws bedrock-agentcore-control delete-oauth2-credential-provider --name bgagent-linear-oauth-<slug>. - When
oauth_secret_arnis absent, attemptDeleteSecretagainst the deterministic name and reportabsentonly on a realResourceNotFoundException. - One line in the new docs section cross-referencing the vault subsection below it.
I am not asking you to delete the credential provider from the handler — that is a cross-service teardown with its own failure modes and deserves its own issue. Reporting it accurately is enough to unblock.
4. Non-blocking suggestions
N1 — the scan/revoke sequence is weaker than its own comment claims. linear-remove-workspace.ts:112-121 scans without ConsistentRead, and the revoke UpdateCommand at :164-170 carries no ConditionExpression: #status = :active. The comment at :96-101 asserts "we never re-run the destructive path on a row that's already been torn down", which an eventually-consistent scan cannot guarantee — two concurrent DELETEs, or a removal issued seconds after add-workspace wrote the row, both slip through as a TOCTOU. The analogous registry scan in shared/jira-tenant-registry.ts:32-38 does use ConsistentRead: true. Impact is small because the path is idempotent, but either tighten the code or soften the comment.
N2 — two wrong cross-references in the pagination rationale. linear-remove-workspace.ts:107-109 cites jira-webhook-processor.ts and shared/linear-issue-lookup.ts as the convention being matched. The former contains no ScanCommand at all; the latter (:131-136) scans this same registry without pagination — a counter-example, not the precedent. The genuine precedent is shared/jira-tenant-registry.ts:32-46. The DynamoDB reasoning in that comment (filter applied after Limit) is correct and worth keeping. Also "paginate to completion" is slightly off: the loop is while (!row && scanKey), so it stops on first match — "until a match or key exhaustion" is exact. Worth noting linear-issue-lookup.ts has the same unpaginated Limit-free scan bug shape on this table; a separate issue.
N3 — the "fail-closes on any status != 'active'" claim is imprecise in five places. linear-remove-workspace.ts:48, :155, cli/src/commands/linear.ts:1956, the LinearRemoveWorkspaceResponse doc, and LINEAR_SETUP_GUIDE.md:320. The resolver has an explicit documented exception at linear-oauth-resolver.ts:392-394: a revoked row whose revoked_reason === 'vault_consent_required' is re-probed rather than refused. I chased whether that makes removal reversible and concluded it does not — every path to status='active' either creates the row fresh or goes through clearWorkspaceRevocation, which atomically REMOVEs revoked_reason (:728), so an active row cannot carry a stale reason for the removal to leave behind. Cleared as unreachable. Still worth two cheap changes: qualify the comments, and have the removal write a distinct revoked_reason (e.g. admin_removed) so the invariant is explicit rather than emergent.
N4 — DeleteSecretCommand input is never asserted. cdk/test/handlers/linear-remove-workspace.test.ts:172 only does expect(secretCall).toBeTruthy(), so it passes if the handler deletes the wrong secret or drops ForceDeleteWithoutRecovery. Both matter — :171-173 explains that no recovery window is wanted so same-slug re-onboarding isn't blocked, and nothing tests it. Assert SecretId and ForceDeleteWithoutRecovery: true.
N5 — JSON.stringify(input).toContain('revoked') is the revoke assertion at test :166, :184, :337. That substring also matches attribute names (revoked_at, revoked_by_platform_user_id), so it passes even if :revoked were set to a wrong status value. Assert at value level: ExpressionAttributeValues[':revoked'] === 'revoked', and [':uid'] === <admin> — who revoked is currently unasserted anywhere.
N6 — three failure paths are untested, and one of them guards a fail-open: (a) the revoke UpdateCommand rejecting — the invariant is 500 plus DeleteSecret never called, otherwise the credential is gone while the row still says active; (b) markSecretDeletionFailed itself rejecting (:196-201 wraps it in .catch()) — should still be a loud SECRET_DELETE_FAILED, not an opaque INTERNAL_ERROR; (c) the --purge DeleteCommand rejecting after the secret is gone (:222-227) — currently a generic 500 the client cannot distinguish from "nothing happened".
N7 — unbounded pagination has no page cap. do { … } while (!row && scanKey) loops as many pages as DynamoDB returns, with the 10s timeout as the only bound. Correct and cheap at the documented tens-of-rows scale, but a max-page guard returning a clean 404/500 beats a timeout. Both existing pagination tests use two pages.
N8 — duplicate active rows for one slug: first match wins, silently. :113-123 takes Items?.[0] and exits on first match, so two active rows sharing a workspace_slug (a Linear urlKey reassigned after a rename, then re-onboarded) means one is torn down and the other keeps an active status and a live secret, reported as success. Consider paginating to completion and returning 409 on a match count > 1.
N9 — the 403 is an existence oracle. :136 returns 403 for an active slug installed by someone else and 404 otherwise, so a non-admin can distinguish "exists and active" from "does not exist". The comment at :93-97 claims the endpoint "is not a revoke-oracle" — accurate as far as revoked-vs-missing goes, but worth narrowing the wording, or collapsing non-admin responses to 404.
N10 — docs: the manual-fallback snippet is in the wrong order. LINEAR_SETUP_GUIDE.md:351-361 says the manual sequence is "equivalent to the default remove-workspace flow" but runs delete-secret first, then the registry revoke — the reverse of the revoke-first fail-closed ordering the guide correctly documents two paragraphs above. Swap them. Also worth stating that re-running remove-workspace after a partial failure returns 404 (the scan filters status='active'), so the manual path is the only recovery.
N11 — check-types-sync.ts:147 exempts the new type rather than asserting it. Adding LinearRemoveWorkspaceResponse to CLI_ONLY_ALLOWLIST is consistent with SlackLinkResponse/LinearLinkResponse/JiraLinkResponse, so it is not a rule break — but nothing pins the successResponse(200, {…}) body at linear-remove-workspace.ts:249-254 to the interface. A satisfies-style annotation on the success body closes it at zero cost.
N12 — style. linear-remove-workspace.ts:34 is the only handler applying a non-null assertion to this env var; the seven siblings keep it string | undefined.
5. Documentation
Good, and verifiably in sync. docs/guides/LINEAR_SETUP_GUIDE.md gained a proper "Removing a workspace" section, a "Deactivating a single project mapping" section, and a Manual fallback that names exactly the three attributes markSecretDeletionFailed writes — the guide/CLI contradiction from the July round is resolved, and --purge semantics at :327 match the code precisely.
Mirror sync: verified clean. Regenerated with node scripts/sync-starlight.mjs; git status --porcelain came back empty. docs/src/content/docs/using/Linear-setup-guide.md is a true generated artifact, not hand-edited, so CI's mutation check will pass.
Gaps: N10 above (fallback command ordering), and the vault cross-reference that is part of B1.
API_CONTRACT.md — checked, and not a gap for this PR. Its endpoint summary lists zero integration routes (no /v1/linear/*, /v1/jira/* or /v1/slack/*) and its error table already omits shipped codes like API_KEY_NOT_FOUND. So omitting DELETE /v1/linear/workspaces/{slug}, WORKSPACE_NOT_FOUND and SECRET_DELETE_FAILED follows the established convention that integration surfaces live in their setup guide. Backfilling the whole integration surface deserves its own issue; it is not this PR's debt.
Governance: #306 carries approved and is assigned to you. Branch feat/issue-306-linear-remove-workspace references the issue; the issue- infix is a trivial deviation from feat/<number>-<desc> and I am treating it as waived.
6. Tests and CI
CI: 7 of 8 green — Analyze (javascript-typescript), CodeQL, Analyze (actions), Analyze (python), Validate PR title, Dead-code detection, Secrets/deps/workflow scan all SUCCESS; build (agentcore) still IN_PROGRESS at review time, so no all-green claim. mergeStateStatus: BLOCKED is purely REVIEW_REQUIRED — my prior approve was auto-dismissed as stale by the new commits, not overruled.
Suites run locally, real output: cdk linear-remove-workspace + linear-integration + linear-oauth-resolver → 88 passed; with agent.test.ts → 223 passed; cli api-client + linear-remove-workspace → 45 passed; tsc --build exit 0 both packages; eslint clean on all five changed sources; check-types-sync.ts → 71 CLI exports validated against 84 CDK exports.
Prior findings — verified fixed, with mutation testing where it counts:
- B1 (
Limit: 1on a filtered Scan) — FIXED.:107-121paginates with noLimit, and the regression test exists in the shape I asked for (test:231-256, two active rows with the target second, asserting the revokeKeyisws-acmerather than merely a 200). Mutation-tested: restoring the original bug shape (Limit: 1, single scan,Items?.[0]) turns 2 tests red. Restoring onlyLimit: 1inside the loop turns 1 red — correctly, since that is merely inefficient, not broken. - N1 (test double modelled
Limitwrongly) — FIXED.routeDdb(test:100-129) now readsExclusiveStartKey._idx, slices byLimit, applies the filter after slicing, and returnsLastEvaluatedKeyeven on an empty matched page. That is real filtered-Scan semantics, and it is what made the mutation above observable. - N2 (three vacuous construct assertions) — FIXED, all three. The function is now identified by a negative env fingerprint with an exactly-one assertion, the role is derived from the function's own
Fn::GetAtt, and the DELETE method is pinned to the{slug}resource id. Mutation-tested: deleting the function turns 4 tests red; movingaddToRolePolicytolinkFnfails the scope test by logical id. The pinning is real, not decorative. - B2 (mapping cleanup no-op) — FIXED by removal, via the route I suggested: the path, the
--keep-mappingsflag, theprojectMappingTablegrant, and the 30s timeout bump are all gone (zero repo-wide hits forkeep-mappings), and the structural impossibility is now stated honestly in the handler doc, both CLI strings, and the guide. - B3 (
--purgecredential leak) — FIXED. Ordering is exactly revoke →DeleteSecret→ row delete gated on success (:162→:177→:230),markSecretDeletionFailedno longer early-returns onpurged, and a failure leaves the row plusorphaned_oauth_secret_arnand returns a loud 500. Both flag combinations covered (test:322,:362). - N3 (timeout comment) — FIXED. 10s, with a comment that is now correct about both its own value and the siblings' explicit
Duration.seconds(10). - B3 corollary (no retry path) — acknowledged rather than mis-documented, which is what I asked for. A revoked slug still 404s permanently; the guide now documents the real manual escape hatch instead of a fictional
--keep-mappingsretry. See N10 for the one sentence still missing.
Bootstrap synth-coverage: not applicable, and verified rather than assumed. Ran jest test/bootstrap → 6 suites / 119 tests / 1 snapshot passed. The new code synthesizes no new CFN type: AWS::Lambda::Function + IAM::Role/Policy + Lambda::Permission + ApiGateway::Resource/Method are all already emitted by webhookFn/webhookProcessorFn/linkFn in the same construct, confirmed by a synth dump. BOOTSTRAP_VERSION correctly not bumped — cdk/src/bootstrap/ is untouched, so the bundle is byte-identical and a bump would tell every operator to re-bootstrap for a no-op. Worth noting the right distinction: secretsmanager:DeleteSecret and the table grants are runtime app-role permissions, not cfn-exec-role permissions, so they belong in linear-integration.ts where they are.
CFN ceilings accounted for. agent.test.ts:1404 is the 3-line change — the #319 UA-attribution exact-Lambda-count guard, 46 → 47, with a comment naming the new function. The post-#854 headroom guard (agent.test.ts:1821-1878, 490-resource budget) is green across all six compute×gateway cells, and the "no test-invoke-stage permission" test stays green because the new integration passes allowTestInvoke: false.
Test-perf rule #366: compliant. One App + one Template.fromStack() in beforeAll; the diff adds no new App() and no bundling-stacks re-enable.
7. Human heuristics
- Proportionality — pass. One Lambda, one route, one paginated lookup, four teardown phases. The July round's over-build (mapping cleanup, its grant, the 30s timeout) was removed rather than defended, which is the right instinct and left the command smaller than when it arrived.
- Coherence — pass, with N2's citations as the exception. Naming, envelopes,
makeClientattribution, nag-suppression extension, and thebgagent-linear-oauth-*wildcard all match sibling code. The wrong precedent citations point at a real inconsistency elsewhere (linear-issue-lookup.tsscans this table unpaginated). - Clarity — concern. The teardown-ordering, idempotency, purge-abort and phase-tracking comments are all accurate and genuinely explain why. Three claims overstate: the fail-closed absolute (N3), the pagination precedent (N2), and the concurrency guarantee (N1) — and
secret_deleted: booleanhides three states behind one word (B1). - Appropriateness — pass on the axis that previously failed. The July round's central flaw was a test double modelling DynamoDB behaviour that does not exist (AI001). That is fixed properly, and the fix is provable — the mutation experiments above show the new double and the pinned construct assertions actually bite. The oauth-resolver additions (
test:313-347) are the right shape too: the revoked case asserts the secret is never even fetched, paired with an identical-token control that isolates status as the only cause. Remaining gaps are assertion strength on failure paths (N4–N6), not mock realism.
Bottom line: everything I blocked on in July is genuinely fixed, and fixed in a way the test suite can now defend — the mutation results are what convinced me. The single blocker is a cross-feature interaction that did not exist when I last approved: on the vault path this command says "removed" while a self-refreshing grant lives on. Report it accurately (return provider_name, print the follow-up, one docs line) and narrow secret_deleted to a three-state union, and I am happy to approve. N1–N12 are all optional.
…evoke race (#681 B1, N1-N7, N10-N12) Addresses PR #681 review feedback on `DELETE /v1/linear/workspaces/{slug}`. B1 (blocking) — the registry lookup could miss an existing workspace. `ScanCommand` was issued with `Limit: 1` plus a `FilterExpression` on `workspace_slug`. DynamoDB applies `Limit` to items *examined*, not items matched, so a filtered scan can legitimately return an empty `Items` array together with a `LastEvaluatedKey` while the target row sits a page deeper. On any table with more than one row the handler therefore 404'd on workspaces that existed. The scan now pages via `ExclusiveStartKey` until the row is found or the keyspace is exhausted, capped at `MAX_SCAN_PAGES` (20) so a pathological table cannot pin the Lambda until timeout — the cap is a 500, not a silent 404, because "we gave up looking" is not "it is not there". `ConsistentRead: true` was added so a removal issued straight after a `linear setup` reads its own write. The paging fix widens, but does not close, a TOCTOU: two concurrent DELETEs could both find the same `active` row and both report success. The revoke `UpdateCommand` now carries `ConditionExpression: '#status = :active'`, so exactly one caller wins; the loser's `ConditionalCheckFailedException` maps to 404 `WORKSPACE_NOT_FOUND` and, critically, does *not* proceed to delete the OAuth secret out from under the winner. N1/N2 — `secret_deleted: boolean` becomes `secret: 'deleted' | 'absent' | 'not_applicable'`. A boolean conflated two very different outcomes: "there was a secret and it is gone now" and "there was never a Secrets Manager secret because this workspace is vault-managed". The latter means teardown is *not* finished — an AgentCore OAuth2 credential provider survives outside CloudFormation, still holding the Linear client secret and a live, self-refreshing grant, and `cdk destroy` will not remove it. The response now echoes `provider_name` for those rows and the CLI prints the exact `aws bedrock-agentcore-control delete-oauth2-credential-provider` follow-up. `not_applicable` is deliberately narrow (`providerName && !oauthSecretArn`): `bgagent linear setup` writes `oauth_secret_arn` unconditionally, so a vault row that also carries an ARN really did have a secret and reports `absent`. N3 — the "removes everything" claims in the CLI prompt and the setup guide were wrong in the vault case. Both now say what is *not* removed, and the pre-confirmation prompt warns before the destructive action rather than only disclosing it afterwards. N7 — the secret delete falls back to the deterministic `bgagent-linear-oauth-<slug>` name when the row records no `oauth_secret_arn`, so a partially-written row does not orphan its secret. `secretsmanager:DeleteSecret` is granted over that name prefix (`linear-integration.ts`), and `SecretId` accepts a name or an ARN, so the by-name call is permitted. The prefix is verified identical in all four of its co-definitions. N11/N12 — `revoked_reason` is now `admin_removed`, not `vault_consent_required`. That distinction is load-bearing: `vault_consent_required` is the one revoked reason the OAuth resolver re-probes instead of refusing, so reusing it here would let a later successful vault probe un-latch a workspace an operator deliberately removed. The vocabulary lives in `LinearRevocationReason` (exported from `shared/linear-oauth-resolver.ts` as a **type only**) and the writer declares its own constant. `import type` is erased before esbuild, so the removal handler takes no runtime dependency on the resolver — a value import would pull SNS alerting, the resolver's DDB/Secrets Manager clients and the token-refresh path into this Lambda's bundle, and would land the handler in the `agent.test.ts` minting-handler census whose entire value is that such an import is a test failure rather than a production 401. N4/N5/N6/N10 — `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is read once at module scope and validated in-handler, so a misconfigured deployment 500s with a named cause instead of an opaque SDK error; the response body is typed by a module-local interface applied with `satisfies`; the 404-on-lost-race path logs a WARN naming `oauth_secret_arn`; the 403 wording no longer implies the workspace exists. Not included, per review scope: N8 (409 on duplicate active rows for one slug — currently first-match-wins) and N9 (collapsing the 403 existence oracle to 404). Both change API semantics and belong in their own issues rather than a bugfix PR. N9's wording half is done here. Tests: 24 in the handler suite (by-name delete, `not_applicable` + `provider_name` echo, vault-row-with-ARN => `absent`, lost race => 404 with no secret delete, non-conditional update failure => 500, page cap => 500 after exactly 20 scans, `--purge` delete failure => 500 with the revoke landed, marker-write failure still surfacing `SECRET_DELETE_FAILED`, and a missing-table-name case in its own module registry). Full suites green: cdk 4579/4579 (216 suites), cli 941/941 (63 suites), docs 77 pages, drift-prevention clean, jira-forge-app 11/11. The agent pytest step of `//agent:quality` was NOT run: this diff contains no Python, and that suite writes stray commits when run from a worktree lacking the #856 git-config isolation. `//agent:lint` and `//agent:typecheck` were run instead, both clean. Refs #306, #681. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@isadeks B1 plus N1–N7 and N10–N12 are in One correction up front, because it will otherwise mislead you: the commit message on B1 — vault-managed teardown reported as complete
The three-state union is what closes both manifestations, as you said.
CLI side, the pre-confirmation prompt now warns before the destructive action rather than only disclosing afterwards: and when The provider name is echoed from the response rather than derived from the slug, and there's a test pinning that (fixture One note on where the type went. I did not add Nits
N11/N3 — where the
|
isadeks
left a comment
There was a problem hiding this comment.
1. Verdict
Approve. The single blocker from my 11 Sep round — vault-managed teardown reporting success while a live, self-refreshing Linear grant survives — is genuinely fixed, and fixed at the root cause I asked for rather than papered over at the call site. All of N1–N7 and N10–N12 landed; N8 and N9 are filed as #883 and #884 with the tradeoffs written out, which is the right call for changes that alter API semantics. Two non-blocking nits below, neither worth another round.
I verified every claim below by reading the code at 253de07f, not by trusting the commit messages or the author's reply. I did not execute the suites — see §6.
2. Prior findings — adjudicated against current code
| Finding | Adjudication |
|---|---|
| B1 (Sep) vault teardown reported done | ✅ FIXED |
B1 (Jul) Limit: 1 filtered Scan 404s |
✅ FIXED, still fixed |
| B2 (Jul) mapping cleanup no-op | ✅ FIXED by removal |
B3 (Jul) --purge credential leak |
✅ FIXED, still fixed |
| N1 TOCTOU on scan+revoke | ✅ FIXED (tightened, not softened) |
| N2 wrong pagination precedent | ✅ FIXED (see nit 1) |
| N3 fail-closed claim imprecise | ✅ FIXED (admin_removed) |
| N4–N7, N10–N12 | ✅ FIXED |
| N8 / N9 | 📋 filed as #883 / #884 |
B1 (Sep) — fixed, and the discriminator is sound. secret_deleted: boolean is now secret: 'deleted' | 'absent' | 'not_applicable' (linear-remove-workspace.ts:98, cli/src/types.ts:659), and provider_name is echoed (:427). I checked the discriminator rather than assuming it: not_applicable is providerName && !oauthSecretArn (:388), and the author is right that provider_name alone would be wrong — bgagent linear setup writes oauth_secret_arn unconditionally in the same PutCommand that writes provider_name (cli/src/commands/linear.ts:1340,1349), so a normal vault row carries both and correctly reports deleted/absent. Critically, the operator-facing follow-up is driven by provider_name, not by the secret outcome (cli/src/commands/linear.ts:2026), so the vault warning fires on every vault path including the common both-fields case — and there are tests pinning both directions (cdk/test/handlers/linear-remove-workspace.test.ts:464, :489). The provider name is printed verbatim from the response with a fixture (legacy-linear-provider-acme-7f3a) that fails a future "just interpolate the slug" refactor. Docs cross-reference the pre-existing credential-provider section (LINEAR_SETUP_GUIDE.md), and the pre-confirmation prompt now warns before the destructive call rather than only disclosing after.
N1 — tightened at both halves, which is the better of the two options I offered. ConsistentRead: true on the lookup scan (:223) and ConditionExpression: '#status = :active' on the revoke (:295). The race-loser path is the part worth calling out: it returns 404 and, per :305-321, never reaches DeleteSecret — deleting the winner's credential out from under it was the real hazard, and there is a test asserting smSend is untouched on that path (test:510). The WARN logging the secret ARN on that branch is a good instinct: if the winner was the resolver latching rather than a peer DELETE, nobody deleted the secret and that line is what makes the orphan findable.
N3 — the terminality invariant is now explicit rather than emergent. admin_removed is deliberately not the reason the resolver re-probes, and I re-verified the mechanism holds: the guard is row.status === 'revoked' && row.revoked_reason === VAULT_CONSENT_REVOCATION_REASON (linear-oauth-resolver.ts:421-423), and clearWorkspaceRevocation's condition pins revoked_reason = :inference (:759-765), so an admin_removed row cannot be un-latched by any vault probe. The import type-only dependency direction is the right answer, and the reasoning about the agent.test.ts minting-handler census is correct — appending to that allowlist would have spent the test's entire signal.
Prior blockers re-verified as still fixed after the rebase. No Limit on the lookup, pagination is while (!row && scanKey) with a MAX_SCAN_PAGES cap (:216-241); ordering is Update-revoke → DeleteSecret → row Delete gated on the secret being confirmed gone (:291 → :337 → :403); markSecretDeletionFailed has no purged special-case (:455); zero repo-wide hits for keep-mappings / mappings_removed / deleteWorkspaceProjectMappings.
3. Vision alignment
Unchanged and strengthened. Routing the destructive path through an authenticated endpoint keeps the DynamoDB and Secrets Manager grants on one Lambda role instead of on every operator's IAM identity — bounded blast radius, and the grant is genuinely narrow (secretsmanager:DeleteSecret alone, scoped to bgagent-linear-oauth-* at linear-integration.ts:515-520; no GetSecretValue, so this role cannot read tokens). Revoke-first makes teardown fail-closed on every flag combination, and the durable secret_deletion_failed / orphaned_oauth_secret_arn marker plus the provider_name echo keep a partial teardown reviewable. The B1 fix is what closes the last gap: the command's report now matches what it actually accomplished. No tenet traded, no ADR owed.
4. Non-blocking nits
Both inline. Summarising:
-
Two stale line citations.
:114and:278both cite the resolver re-probe guard aslinear-oauth-resolver.ts:392-394; the guard is at:421-423(:392is theresolveLinearOauthTokensignature). Everything else I spot-checked is accurate —jira-tenant-registry.ts:32-46really is a paginatedConsistentReadscan of this shape,linear-issue-lookup.ts:131-136really is the unpaginated counter-example,linear-integration.ts:519andlinear-identity-vault.ts:51are both right, and all four env-var siblings atlinear-webhook.ts:41/linear-webhook-processor.ts:88/orchestration-reconciler.ts:96/github-webhook-processor.ts:61check out. Flagging only because a wrong line number in a load-bearing comment is the same class as last round's N2, and this file's comments are unusually trusted precisely because they are usually right. Prefer naming the symbol (latchedOnVaultInference) over a line range that rots. -
--purgeon a vault-managed row deletes the row that heldprovider_name. Fail-closed still holds and the response still echoes the name, so the operator does see the follow-up — but the durable pointer to the surviving credential provider is gone, leaving CloudWatch retention as the only record. That is the same asymmetry the B3 marker exists to prevent, one service over. Cheapest options: refuse--purgewhenprovider_nameis set until the provider is gone, or say so in the--purgedocs bullet, which is currently silent on it. No test covers purge + vault.
5. Documentation
Good, and materially better than what it replaced. The new "Removing a workspace" section leads with the command, states the three secret outcomes, and carries the vault caveat as a blockquote linking the pre-existing credential-provider section. N10 landed correctly: the manual fallback now runs the registry revoke before delete-secret, mirrors the handler's --condition-expression, and the paragraph explaining why that order matters (an active row with no credential behind it fails per-event without latching, so the receiver 500s and Linear retries) is the kind of rationale that keeps an operator from "fixing" the order back. The re-run-returns-404 note is there, including the part that matters — that the 404 does not distinguish revoked from never-existed.
Mirror sync: verified. I diffed the changed hunks of docs/src/content/docs/using/Linear-setup-guide.md against docs/guides/LINEAR_SETUP_GUIDE.md — identical, generated not hand-edited, so CI's mutation guard is clean. remove-workspace is documented only in the Linear setup guide, which matches the sibling convention (add-workspace and update-webhook-secret appear nowhere else either), so there is no CLI-reference gap.
Governance: #306 carries approved (ADR-003 satisfied). The issue- infix in the branch name is a trivial, waived deviation. Follow-ups #687, #883 and #884 all exist as claimed.
6. Tests and CI — and what I relied on
Disclosure: I did not run the suites. node_modules is absent from my review worktree, so nothing below is an execution claim. I read the tests and relied on the reported CI state.
CI: 8 of 8 SUCCESS — CodeQL and the three Analyze jobs, build (agentcore), Validate PR title, Dead-code detection, Secrets, deps, and workflow scan. Worth being explicit that none of those runs the CDK or CLI unit suites, so the 4579/941 figures rest on the author's local run plus my reading of the assertions.
Reading them, the assertions are strong in the way that matters — they pin values, not shapes. The happy path asserts ExpressionAttributeValues[':revoked'] === 'revoked', [':reason'] === 'admin_removed', [':uid'] === ADMIN, ConditionExpression === '#status = :active', zero Delete calls, and DeleteSecret input toEqual({ SecretId, ForceDeleteWithoutRecovery: true }) — no JSON.stringify().toContain() survives, and ForceDeleteWithoutRecovery is now actually under test (N4/N5 closed properly). All three N6 failure paths are covered, each asserting the invariant rather than just the status code: revoke-rejects ⇒ DeleteSecret never called; marker-write-rejects ⇒ still SECRET_DELETE_FAILED, not INTERNAL_ERROR; purge-delete-rejects ⇒ 500 that does not claim purged. The missing-env test asserting zero DDB and SM calls is a nice touch. 24 handler tests, and the construct suite's findRemoveWorkspaceFn() derives the role from the function's own Fn::GetAtt rather than from the DeleteSecret policy, which is what makes the grant-scope assertion non-tautological.
Bootstrap synth-coverage: not applicable. No new CloudFormation resource type — AWS::Lambda::Function, IAM::Role/Policy, Lambda::Permission, ApiGateway::Resource/Method are all already emitted by webhookFn/webhookProcessorFn/linkFn in this same construct. cdk/src/bootstrap/ is untouched, so BOOTSTRAP_VERSION correctly stays put — bumping it would tell every operator to re-bootstrap for a byte-identical bundle. secretsmanager:DeleteSecret and the table grants are runtime app-role permissions, not cfn-exec-role permissions, so linear-integration.ts is where they belong. The #319 attribution census is updated 46 → 47 with a comment naming the new function, and the integration passes allowTestInvoke: false.
Test-perf #366: compliant. One App + one Template.fromStack() in beforeAll; no new App() and no bundling-stacks re-enable in the diff.
On the two hook skips in the push. SKIP=monorepo-security-pre-push,monorepo-tests-pre-push rather than blanket --no-verify, with the substitute evidence spelled out — pre-existing security:sast finding scoped to a file this branch does not touch, masking findings git blamed to origin/main ancestors (with the correct note that the raw line numbers were misleading after a 102-line insertion), and gitleaks scoped to origin/main..HEAD. That is the right way to skip a hook: prove the finding is not yours rather than assert it. Same for the //agent:quality pytest disclosure — no Python in the diff, and //agent:lint + //agent:typecheck were run instead.
7. Review agents
Same disclosure as my prior rounds, and it still applies: no agent-spawn tool is reachable in this review context, so I could not fan the pr-review-toolkit agents out as subagents. I executed each in-scope rubric directly against the diff instead, and this round leaned on cross-reference verification rather than mutation (the mutation work was done in July and September and the code under it has not moved).
- code-reviewer — routing matches AGENTS.md (
cdk/handler + route,cli/command), L2 constructs, no hardcoded ARNs (Stack.of(this).formatArn), timeout constant extracted. - silent-failure-hunter — the core of this round.
ResourceNotFoundExceptionis still the only swallowed SM error and it is name-checked; every other SM failure writes the marker and returnsSECRET_DELETE_FAILED; the marker write's own.catch()logs and does not mask the outer error; the race-loser 404 does not proceed to a destructive call. The success-shaped-report-over-nothing-happened family is closed. - comment-analyzer — this is where nit 1 came from; I checked all eight cross-references in the new comments and seven are accurate.
- type-design-analyzer — the
secretthree-state union is the right fix for the absent-vs-not-applicable conflation, and inlining it rather than exporting an alias is correct givencheck-types-sync.ts's rule.RemoveWorkspaceResponseBodyapplied withsatisfiescloses N11 at zero cost. - pr-test-analyzer — read all 24 handler tests and 11 CLI tests; the only gap I would still name is purge + vault (nit 2).
- security-review — IAM scope, admin-only via
installed_by_platform_user_id, anchored slug regex applied before any AWS call, logs carry slug / workspace id / secret id / booleans only. The 403 existence-oracle narrowing is correctly deferred to #884 with the tradeoff stated rather than silently collapsed. - code-simplifier — omitted; the diff's own direction this round was narrowing a type and deleting a special case.
8. Human heuristics
- Proportionality — pass. One Lambda, one route, four teardown phases, a page cap. The three-state union is the smallest change that closes both manifestations of the blocker; nothing was invented around it.
- Coherence — pass. The revocation vocabulary now has a single authority (
LinearRevocationReasonin the resolver) with a type-only dependency edge, which is a genuinely better shape than a duplicated string literal. TheConsistentRead+ pagination precedent citation is the right one this time. - Clarity — pass, with nit 1. The comments explain why at a standard I wish were more common — the purge-ordering rationale and the race-loser WARN are both load-bearing and both correct. Two line numbers have rotted.
- Appropriateness — pass. Assertions are value-level, failure paths assert invariants rather than status codes, and the test double models real filtered-Scan semantics. The N8/N9 split — behaviour changes filed, wording landed — is the correct instinct for a bugfix PR.
Nothing here should hold the merge. Nice work across four rounds; the willingness to delete a path rather than defend it, twice, is what got this to a clean state.
…sage Empty commit. No code, test, or docs change — this exists only to correct the record, because `253de07f`'s message cannot be amended without force-pushing a shared PR branch. `253de07f` labels its sections with a B1/N-numbering that does NOT correspond to review 5181793802 on PR #681. Every change it describes is a change that was actually made, and the descriptions are accurate; only the labels are wrong. Read the labels below, not the ones in that message. Root of the confusion: PR #681 has had two "B1"s. July's round 4807516158 had B1 = the `Limit: 1` filtered-Scan bug, fixed back then in `0d006b04`. The current round 5181793802 has B1 = vault-managed teardown reported as complete. `253de07f`'s message narrates the already-landed July fix under the current round's B1 heading, which makes the pagination code read as new work when it predates the commit. Authoritative mapping of review 5181793802 to what `253de07f` changed: B1 vault-managed teardown reported as complete. `secret_deleted: boolean` -> `secret: 'deleted' | 'absent' | 'not_applicable'`, response echoes `provider_name`, CLI prints the `delete-oauth2-credential-provider` follow-up, prompt warns before the destructive action, guide updated. (253de07's message calls this "N1/N2".) N1 `ConsistentRead: true` on the lookup scan + `ConditionExpression: '#status = :active'` on the revoke, so the loser of a race 404s and never reaches `DeleteSecret`. (253de07's message folds this into its "B1" section.) N2 pagination-rationale cross-references corrected: dropped `jira-webhook-processor.ts`, cited `shared/jira-tenant-registry.ts:32-46` as the genuine precedent and as the `ConsistentRead` precedent, kept `linear-issue-lookup.ts:131-136` relabelled a counter-example, and replaced "paginate to completion" with "until a match or key exhaustion". (253de07's message does not label this at all.) N3 the `status != 'active'` claim qualified in five places, and the removal now writes the distinct `revoked_reason: 'admin_removed'`. (253de07's message splits this across its "N3" and "N11/N12".) N4 `DeleteSecretCommand` input asserted exactly: `SecretId` plus `ForceDeleteWithoutRecovery: true`. (253de07's message lists this under "N4/N5/N6/N10" without detail.) N5 all three `JSON.stringify(...).toContain('revoked')` assertions replaced with value-level `[':revoked'] === 'revoked'` and `[':uid'] === ADMIN`. N6 the three untested failure paths covered: revoke rejecting (500, no `DeleteSecret`), `markSecretDeletionFailed` rejecting (still `SECRET_DELETE_FAILED`), `--purge` `DeleteCommand` rejecting (500 with the revoke asserted landed). N7 `MAX_SCAN_PAGES = 20`, returning 500 rather than 404. (253de07's message uses "N7" for the by-name secret-delete fallback, which was not a numbered review item.) N8 NOT in this commit. Filed as #883 (409 on duplicate active rows). N9 wording half only. The behavioural half is filed as #884. N10 manual-fallback snippet reordered to revoke-first with `--condition-expression '#s = :active'`, plus the note that re-running `remove-workspace` on an already-removed workspace returns 404. N11 module-local `RemoveWorkspaceResponseBody` applied with `satisfies` at the return. (253de07's message lists this under "N4/N5/N6/N10".) N12 `WORKSPACE_REGISTRY_TABLE` is plain `string | undefined`, no non-null assertion, validated once in-handler. (253de07's message lists this under "N4/N5/N6/N10".) Also not a numbered review item, and therefore unlabelled rather than mislabelled: the by-name `bgagent-linear-oauth-<slug>` fallback for the secret delete when the row records no `oauth_secret_arn`. The full mapping with reasoning is in the PR comment: #681 (comment) Refs #306, #681, #883, #884. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Follow-up: Worth flagging that the mislabeling in Root cause of the confusion, for the record: this PR has had two B1s. Your July round's B1 was the CI on |
Summary
Adds a
bgagent linear remove-workspace <slug>command that deregisters a Linear workspace, replacing the manual DynamoDB + Secrets Manager surgery removal previously required. Ships the full flow: CLI subcommand, an authenticatedDELETE /v1/linear/workspaces/{slug}REST endpoint + Lambda handler, and a re-verified fail-closed OAuth resolver.Closes #306
Root cause / contract
There was no removal path. Onboarding is one command (
bgagent linear setup/add-workspace), but removal meant hand-editing theLinearWorkspaceRegistryTablerow, deleting thebgagent-linear-oauth-<slug>secret, and cleaning up project mappings — error-prone, undocumented, and prone to leaving dangling secrets or stale registry rows the resolver still reads.cli/src/commands/linear.ts(siblings mirrored:add-workspace,update-webhook-secret).cdk/src/handlers/linear-*.ts); routes are added incdk/src/constructs/linear-integration.ts.cdk/src/handlers/shared/linear-oauth-resolver.tsalready fail-closes on any registrystatus != 'active'(re-verified under test per the AC).The fix
CLI (
cli/src/commands/linear.ts,api-client.ts,types.ts): newremove-workspacesubcommand mirroring the sibling slug-validation + confirmation UX (type the slug to confirm; skipped by--yes). All destructive work is delegated to the backend viaApiClient.linearRemoveWorkspace()(DELETE), so DDB / Secrets Manager grants stay on the API role, not on every CLI user — same delegation pattern aslink. Flags:--purge,--keep-mappings,--yes.Handler (
cdk/src/handlers/linear-remove-workspace.ts, new, flat): Cognito-authenticated, admin-only (caller must match the recordedinstalled_by_platform_user_id). Default is a SOFT removal — flip the registry row tostatus='revoked'(audit trail preserved) and delete thebgagent-linear-oauth-<slug>secret.--purgedeletes the row outright. Secret deletion is idempotent (ResourceNotFoundExceptionswallowed, other SM errors rethrown). Optional project-mapping cleanup keyed onlinear_workspace_id.Route + IAM (
cdk/src/constructs/linear-integration.ts):DELETE /v1/linear/workspaces/{slug}behind the Cognito authorizer; new Lambda gets read/write on the registry + project-mapping tables andsecretsmanager:DeleteSecreton the documentedbgagent-linear-oauth-*prefix (same wildcard the webhook Lambdas already use). cdk-nag clean.Fail-closed resolver (
cdk/src/handlers/shared/linear-oauth-resolver.ts): already rejects any non-activestatus — so a revoked workspace stops resolving tokens and routing webhooks the instant this returns. Added an adversarial test.Support files (sanctioned by the respective hooks): added
WORKSPACE_NOT_FOUNDtocdk/src/handlers/shared/response.ts; addedLinearRemoveWorkspaceResponseto theCLI_ONLY_ALLOWLISTinscripts/check-types-sync.ts(it's a client-only response envelope, exactly like the existingLinearLinkResponse— CDK builds the body inline).Docs: rewrote the "Removing a workspace" section of
docs/guides/LINEAR_SETUP_GUIDE.mdto lead with the command and keep manual DDB steps as a fallback (Starlight mirror regenerated viamise //docs:sync).Testing
cli/test/commands/linear-remove-workspace.test.ts):--yesskips prompt + calls DELETE with default flags;--purge/--keep-mappingsforward the right query params; invalid slug rejected without hitting the API; API errors surface (not swallowed); mapping-removal count reported. 6/6 pass.cdk/test/handlers/linear-remove-workspace.test.ts): 401 (no JWT), 400 (bad slug), 404 (not found), 403 (not admin — no destructive calls), happy-path revoke + secret delete,--purgedeletes row, secret-already-gone idempotent, mapping cleanup,--keep-mappingsno-op, already-revoked → 404 (no re-revoke). 10/10 pass.cdk/test/handlers/shared/linear-oauth-resolver.test.ts): a revoked slug is REJECTED even with a fully valid non-expiring secret, and the secret is never fetched; active-control case still resolves. Plus the existingstatus != active → nullcoverage.cdk/test/constructs/linear-integration.test.ts): 4 Lambdas,workspaces/{slug}resources, DELETE method is Cognito-authorized, env wiring,DeleteSecretIAM.mise //cli:build(648 tests) pass,mise //cdk:buildtest phase (2548 tests) pass, cdk-nag clean for the new construct (verifiedRemoveWorkspace nag findings: []),security:sastexit 0,security:deps0 advisories,security:sast:maskingclean on my files.Security note
Fail-closed by design: a revoked or unknown slug is REJECTED, never resolved (404 also avoids the endpoint acting as a revoke-oracle). Admin-only removal; legacy rows missing
installed_by_platform_user_idcan only be removed via the documented manual fallback (fail-closed default). No secrets are logged — the handler logs only slug / workspace_id / request_id / booleans. Reuses existing AWS SDK clients; no new dependencies.Dependencies / related
cli/src/commands/linear.ts,cdk/src/constructs/linear-integration.ts, andcdk/src/handlers/shared/linear-oauth-resolver.ts. Edits here are localized (additive subcommand, additive route block) to keep the rebase mechanical — rebase ordering will be needed between this PR and feat(observability): solution attribution via native AWS_SDK_UA_APP_ID (#319, alt to #338) #345.Notes / follow-ups (not fixed here — out of scope)
LinearProjectMappingTablerows are keyed onlinear_project_idand carry no workspace identifier in the current schema (onboard-projectwrites onlyrepo/label_filter/team_id). Mapping cleanup therefore matches on alinear_workspace_idfield that today's rows don't have, so it's effectively a safe no-op until that field is recorded at onboard time. Documented in the guide; a schema addition (recordlinear_workspace_idon mapping rows) would make cleanup fully effective — candidate follow-up issue.ts-silent-success-maskingfindings oncli/src/commands/linear.ts:1765andcli/src/linear-oauth.ts:370are onmain, outside this diff.🤖 Generated with Claude Code
Self-review follow-up (looped
/review_pr, iteration 1)Ran
pr-review-toolkitagents (code-reviewer,silent-failure-hunter,pr-test-analyzer). Blocking findings addressed in follow-up commits:500 SECRET_DELETE_FAILEDand persists a durablesecret_deletion_failed/orphaned_oauth_secret_arnmarker on the registry row so the leak is discoverable.ResourceNotFoundExceptionstays idempotent (200).status='active'FilterExpression to the handler (not the mock); paginated mapping cleanup acrossLastEvaluatedKey; missing-oauth_secret_arnskip; the CLI confirmation-prompt abort/proceed branch (AC-required "prompts" surface);secret_deleted:falseCLI output; and the api-clientpurge/keep_mappingsquery-string mapping.Deferred nits (non-blocking):
BatchWriteCommandfor large mapping cleanups, and treating an SMInvalidRequestException"scheduled for deletion" as idempotent — candidate follow-ups, not needed for correctness here.Review remediation (@isadeks — changes requested)
Remediated all 7 threads (3 blocking + 4 nits). Force-pushed after a clean rebase onto
main.Limit: 1on a filtered Scan 404'd live workspaces. DroppedLimit; the registry lookup now paginates to completion (followsLastEvaluatedKey, stops as soon as a filtered row matches), matching the small-table convention injira-webhook-processor.ts/shared/linear-issue-lookup.ts. Taught the test double to honorLimit/ExclusiveStartKeyand apply the filter to the examined slice (this is why B1 was invisible), and added a two-active-row regression with the target on page two (404s before, passes after) plus a follow-LastEvaluatedKeyassertion.LinearProjectMappingTablerows carry no workspace id, so thelinear_workspace_idfilter matched zero rows always while the CLI printed✓ 0 project mapping(s) removed. DeleteddeleteWorkspaceProjectMappings+ its call, the--keep-mappingsflag,mappings_removedfrom the response type, theprojectMappingTable.grantReadWriteDatagrant, theLINEAR_PROJECT_MAPPING_TABLE_NAMEenv, and the timeout bump. CLI output andLINEAR_SETUP_GUIDEno longer claim mapping cleanup — mappings are removed by project id. Follow-up (to be filed by the orchestrator): recordlinear_workspace_idon mapping rows at onboard time to enable workspace-scoped cleanup.--purge+ secret-delete failure leaked a credential with no marker. Reordered toUpdate(status=revoked)→DeleteSecret→Delete(row): the row is always an Update first and is hard-deleted only after the secret is confirmed gone, so the durable orphaned-secret marker lands on every flag combination and fail-closed holds. Dropped thepurgedspecial-case inmarkSecretDeletionFailed; corrected both backwards comments. Added a--purge+secret-fail marker regression and an ordering test.RemoveWorkspaceFnis resolved unambiguously via its uniquesecretsmanager:DeleteSecretrole grant; the env test asserts it wires ONLY the workspace registry (no mapping table); the DELETE test is pinned to the{slug}resource; the DeleteSecret grant is asserted bound to that role AND scoped tobgagent-linear-oauth-*.Duration.seconds(10)matching the sibling link/webhook handlers, and the comment states the honest rationale.Supersedes the earlier "Project-mapping cleanup caveat" and the paginated-mapping-cleanup /
keep_mappingsnotes above — that path no longer exists in this PR.Gates:
mise //cdk:compileclean,//cdk:test2554 pass,//cli:build653 pass, cdk-nag 0 rule findings,security:sastexit 0,security:sast:maskingunchanged vsmain(identical 5-finding pre-existing baseline; no net-new), docs mirror in sync, secrets clean on this diff (the only full-history gitleaks hits are the 3 known false positives in1cfa5b9f).Approval-nit remediation (commit d84ea37, @scottschreckengaust agent:w7)
Addressed the 4 non-blocking nits from @isadeks's approval (comment-/test-/docs-only; no source-logic changes):
findRemoveWorkspaceFn()now derives the remove-workspace role INDEPENDENTLY from the function resource (its registry-onlyEnvironmentsignature +Role: Fn::GetAtt), not from the DeleteSecret policy. The secret-prefix test asserts the grant lands on that role, so mis-wiring the grant to another role now fails (verified: moving it tolinkFnfailstoContain(role)). Resource-scope pin tobgagent-linear-oauth-*retained.:270corrected to "Page 1: empty (no matching row) + a continuation key" to matchItems: [];:262-265preamble trimmed of stale routeDdbLimitprose. Test logic unchanged.secret_deletion_failed/secret_deletion_error/orphaned_oauth_secret_arn) and theSECRET_DELETE_FAILEDerror code beside the manualdelete-secretfallback; Starlight mirror regenerated.Note: this additive push dismisses the stale approval; re-request review as needed.