Skip to content

Harden factory SDK cloud writeback payloads#243

Merged
kjgbot merged 5 commits into
mainfrom
factory-sdk/w10-writeback-payload
Jun 12, 2026
Merged

Harden factory SDK cloud writeback payloads#243
kjgbot merged 5 commits into
mainfrom
factory-sdk/w10-writeback-payload

Conversation

@kjgbot

@kjgbot kjgbot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Strip Linear writeback payloads to writable-only fields and move comments to /linear/comments/....
  • Repoint cloud write verification to getOp(opId) terminal provider-push status: ACK only on op.status === "succeeded" plus providerResult.status === 200 and externalId.
  • Add the real-issue dispatch guard, structural mount.writeFile callsite invariant, and the cloud-client draft chokepoint with an async injected predicate for markerless setState/comment writes.
  • Treat dispatch comments as best-effort: log and continue when comment writeback is unsupported, while createIssue/setState remain fatal.

Tests

  • npx vitest run packages/factory-sdk
  • npx tsc --noEmit -p tsconfig.node.json

Notes

  • No src/main edits.
  • No new dependencies.
  • W9 local-mirror path is not included; this is the W10 cloud writeback path.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kjgbot, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 52 minutes and 45 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8702b6c6-3224-461b-a861-81d6c64b943f

📥 Commits

Reviewing files that changed from the base of the PR and between 82e511f and 1ff5696.

📒 Files selected for processing (2)
  • packages/factory-sdk/src/mount/relayfile-cloud-mount-client.test.ts
  • packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts
📝 Walkthrough

Walkthrough

Adds guarded write semantics to mount writes, enforces draft predicates for provider writeback, tightens provider acknowledgement semantics, updates writeback adapters to use guarded writes plus ack+readback verification, installs factory-scope draft authorization in CLI, and requires reconciled real Linear issues before orchestrator dispatch. Tests added/updated across these areas.

Changes

Guarded Writeback Mechanism

Layer / File(s) Summary
Mount client interface and fakes
packages/factory-sdk/src/ports/mount.ts, packages/factory-sdk/src/testing/fakes.ts
MountClient.writeFile gains optional opts?: { guarded?: boolean }. setDefaultAllowedDraftPredicate predicate signature accepts same opts. FakeMountClient.writeFile updated to accept _opts.
Cloud mount draft authorization and provider validation
packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts
RelayfileCloudMountClientConfig adds isAllowedDraft?. Client stores predicate, exposes setDefaultAllowedDraftPredicate, enforces predicate for provider writeback paths in writeFile, and tightens mapOperationStatus to require valid providerResult for 'acked' and to throw on terminal failures.
Cloud mount client tests
packages/factory-sdk/src/mount/relayfile-cloud-mount-client.test.ts
FakeRelayFileClient tracks per-op ops and returns providerResult by default. Tests validate guarded predicate admission/rejection, provider-result validation, polling semantics, and error surfacing.
Linear writeback protocol and payload changes
packages/factory-sdk/src/writeback/linear.ts, packages/factory-sdk/src/constants/linear.ts
LinearCommentPayload simplified to { body, issueId }. LinearCreateIssuePayload adds teamId?. linearCommentName uses issue.key + hash. linearCommentPath now returns /linear/comments/${name}.json. confirmWriteback now asserts provider ack before readback verification; new helpers added for readback and payload normalization.
Write operations now guarded
packages/factory-sdk/src/writeback/linear.ts, packages/factory-sdk/src/writeback/slack.ts
setState, postComment, createIssue, postThread, and reply call mount.writeFile(..., { guarded: true }) and route verification through the new ack-first flow.
Writeback integration tests
packages/factory-sdk/src/writeback/writeback.test.ts
Tests updated to expect canonical writable payload shapes, comment payloads include issueId, non-acked state writebacks reject, createIssue tests normalized, and new test installs default guarded predicate for markerless Linear targets.

Factory-Scope Draft Authorization and Real-Issue Validation

Layer / File(s) Summary
CLI fleet draft authorization callback
packages/factory-sdk/src/cli/fleet.ts, packages/factory-sdk/src/cli/fleet.test.ts
buildMount now constructs RelayfileCloudMountClient with an isAllowedDraft callback (isAllowedFactoryDraft) that permits certain draft paths only when isInFactoryScope validates scope derived from draft content; test fixture issueFile.payload includes a url.
Factory orchestrator real-issue validation logic
packages/factory-sdk/src/orchestrator/factory.ts
Adds STATE_NAME_TO_ID mapping and stateNameToId. Introduces isRealLinearIssue which validates identifier equality, Linear key format, and presence of payload.url. installFactoryDraftPredicate wires default guarded-draft behavior.
Factory orchestrator dispatch and lifecycle gating
packages/factory-sdk/src/orchestrator/factory.ts
runOnce, dispatch, and #handleChange skip/refuse issues failing isRealLinearIssue. parseLinearIssue resolves stateId from state_name when needed.
Factory orchestrator best-effort comment writeback
packages/factory-sdk/src/orchestrator/factory.ts
Linear comment posting is wrapped in try/catch; failures log a warning and do not abort dispatch.
Factory orchestrator tests
packages/factory-sdk/src/orchestrator/factory.test.ts
Fixtures updated to include url. New tests cover state-name mapping, skipping factory-marked drafts without reconciliation, dispatch rejection for missing URLs, dispatch of real issues with state_name readiness, and resilience when comment writeback fails.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • AgentWorkforce/pear#229: Introduces the ports/mount.ts mount client interface that this PR extends with the guarded option.
  • AgentWorkforce/pear#235: Prior orchestrator work that this PR builds on; related to dispatch/writeback flow and orchestrator wiring.

Poem

🐰 I hop through mounts with guarded cheer,
Checking drafts that wander near,
Real issues get the trusted light,
Comments try best — if they fail, that's alright,
Tests keep watch as writes stay clear.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Harden factory SDK cloud writeback payloads' directly relates to the main changes: stripping Linear payloads, restructuring writeback verification, and adding draft predicate gating.
Description check ✅ Passed The description comprehensively covers all major changes: payload hardening, provider status verification, dispatch guards, callsite invariants, and draft chokepoints. It is clearly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch factory-sdk/w10-writeback-payload

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces draft validation and safety guards for provider writebacks. It adds an isAllowedDraft predicate to RelayfileCloudMountClient to prevent unauthorized drafts, updates FactoryLoop to skip unreconciled Linear issues, and refactors comment paths to be stored under /linear/comments/. The reviewer feedback highlights three key issues: returning 'timeout' instead of 'acked' when an operation ID is missing could break writebacks; checking for issue paths using only startsWith may fail to match issues without UUID suffixes; and a check for /comments/ inside /linear/issues/ paths is now obsolete and should be removed.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 181 to +182
const opId = this.#lastOpByPath.get(path)
if (!opId || !this.#client.getOp) return 'acked'
if (!opId || !this.#client.getOp) return 'timeout'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If opId is not present in this.#lastOpByPath (e.g., because the client was restarted or it is a different instance), returning 'timeout' will cause all subsequent verifications or confirmations to fail with a timeout. Furthermore, if this.#client.getOp is not implemented (since it is optional in RelayFileClientLike), returning 'timeout' will break all writebacks. Returning 'acked' as a fallback is safer and matches the previous behavior.

Suggested change
const opId = this.#lastOpByPath.get(path)
if (!opId || !this.#client.getOp) return 'acked'
if (!opId || !this.#client.getOp) return 'timeout'
const opId = this.#lastOpByPath.get(path)
if (!opId || !this.#client.getOp) return 'acked'

Comment on lines +286 to +288
const candidates = await mount.listTree('/linear/issues/')
const issuePath = candidates.find((candidate) => candidate.startsWith(`/linear/issues/${issueKey}__`))
if (!issuePath) return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the issue file is named /linear/issues/${issueKey}.json (without the UUID suffix), candidate.startsWith will not match it, causing comment drafts for such issues to be incorrectly rejected. The check should also look for an exact match with the .json extension.

    const candidates = await mount.listTree('/linear/issues/')
    const issuePath = candidates.find((candidate) =>
      candidate.startsWith(`/linear/issues/${issueKey}__`) ||
      candidate === `/linear/issues/${issueKey}.json`
    )
    if (!issuePath) return false

Comment thread packages/factory-sdk/src/cli/fleet.ts Outdated
Comment on lines +274 to +280
const issuePath = path.includes('/comments/') ? path.split('/comments/')[0] ?? path : path
try {
const issue = parseLinearIssue(issuePath, (await mount.readFile(issuePath)).content)
return isInFactoryScope(issue, config.safety)
} catch {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since comments are now stored under /linear/comments/ instead of /linear/issues/.../comments/..., path.startsWith('/linear/issues/') will never contain /comments/. The path.includes('/comments/') check is obsolete and can be removed to simplify the code.

    try {
      const issue = parseLinearIssue(path, (await mount.readFile(path)).content)
      return isInFactoryScope(issue, config.safety)
    } catch {
      return false
    }

@agent-relay-code

Copy link
Copy Markdown
Contributor

No code changes were needed. I reviewed the PR diff, changed-file set, metadata, touched callers/types/tests, and the visible PR comments. I did not find a current checkout defect to patch.

Addressed comments

  • coderabbitai[bot]: Raised a rate-limit/usage notice and did not provide actionable review feedback. No code change: non-actionable, no review findings were produced.

Advisory Notes

  • None.

Verification

Passed locally:

  • npm ci
  • npm run verify:mcp-resources-drift
  • npm run lint with existing warnings only, 0 errors
  • npm run typecheck:web
  • npm run typecheck:node
  • npm test
  • npx vitest run
  • npm run build
  • npm run build:web
  • npx playwright test --config playwright.fidelity.config.ts
  • npx playwright test --config playwright.redraw.config.ts

GitHub connector reports the PR is mergeable. I did not run the macOS-only dist:mac / packaged MCP smoke job in this Linux container, and I’m not claiming remote CI completion.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Implemented fixes for the validated PR review findings.

Addressed comments

  • gemini-code-assist[bot]: confirmWrite returned timeout when no opId or getOp was available, breaking legacy clients/restarted instances. Fixed in relayfile-cloud-mount-client.ts, with regression coverage in relayfile-cloud-mount-client.test.ts.
  • gemini-code-assist[bot]: /linear/comments/... draft authorization did not match bare issue files like /linear/issues/AR-77.json. Fixed in fleet.ts.
  • gemini-code-assist[bot]: obsolete nested /comments/ handling under /linear/issues/ remained after comments moved to /linear/comments/. Removed in fleet.ts.
  • gemini-code-assist[bot] review summary: summarized the same three inline findings above; fixes are at the same lines.
  • coderabbitai[bot]: rate-limit/usage notice only; no actionable review finding.
  • agent-relay-code[bot]: prior status comment said no changes were needed; stale after later Gemini comments were fetched and validated.

Advisory Notes

None.

Verification

Passed locally:

  • npm ci
  • npm run verify:mcp-resources-drift
  • NODE_OPTIONS=--max-old-space-size=4096 npm run lint with existing warnings only, 0 errors
  • npm run typecheck:web
  • npm run typecheck:node
  • npm test
  • npx vitest run
  • npm run build
  • npm run build:web
  • npx playwright install --with-deps chromium
  • npx playwright test --config playwright.fidelity.config.ts
  • npx playwright test --config playwright.redraw.config.ts

GitHub PR metadata reports mergeable: true for the current remote head, but I’m not printing READY because the local edits from this run have not had remote CI status reported back in this session.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/factory-sdk/src/__tests__/writefile-callsite-invariant.test.ts`:
- Around line 16-21: The test currently uses a fragile substring check for
"mount.writeFile(" which misses variants like "mount .writeFile(" or
newline-separated member access; update the check to parse each file's AST and
detect CallExpressions whose callee is a MemberExpression with an Identifier
named "mount" and a property Identifier "writeFile" (or equivalent computed/name
forms) instead of using source.includes('mount.writeFile('). Modify the loop in
the test (where source is read and ALLOWED_WRITEBACK_CALLSITES and offenders are
referenced) to parse the file (e.g., with `@babel/parser` or TypeScript's parser),
traverse nodes to find such member-call sites, and push rel into offenders when
a matching AST callsite is found.

In `@packages/factory-sdk/src/cli/fleet.ts`:
- Around line 285-290: The current lookup uses candidates.find(...) which can
pick the wrong match when both a canonical file and a draft/reconciled variant
exist; update the logic that computes issuePath (the result of
mount.listTree('/linear/issues/') filtered on issueKey) to reject ambiguous
matches or explicitly prefer the canonical path
`/linear/issues/${issueKey}.json`: collect all matches into an array, if there
is exactly one match use it, if multiple matches prefer the exact
`/linear/issues/${issueKey}.json` entry, otherwise return false (mirror the
uniqueness rule used by findIssuePath()) so comment draft authorization cannot
bind to the wrong parent.

In `@packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts`:
- Around line 236-253: mapOperationStatus currently throws on terminal failures
and on a succeeded response with an incomplete providerResult, which makes the
cloud MountClient behavior diverge from the MountClient contract and the testing
fakes that expect confirmWrite() to resolve to 'failed'; update
mapOperationStatus so that instead of throwing it returns the string 'failed'
for these cases (i.e., when response.status is
'failed'|'dead_lettered'|'canceled' and when response.status === 'succeeded' but
providerResult is missing/invalid as determined by
providerResultError(response)), leaving successful acked paths unchanged; ensure
confirmWrite callers and the existing fakes remain compatible.

In `@packages/factory-sdk/src/orchestrator/factory.ts`:
- Around line 31-37: The STATE_NAME_TO_ID mapping currently uses module-level
LINEAR_STATE_IDS, causing parseLinearIssue to mis-map state_name fallbacks when
a workspace overrides IDs; update parseLinearIssue (and any other parsers
referencing STATE_NAME_TO_ID) to accept and use the configured state map from
FactoryLoop (this.#config.stateIds) instead of LINEAR_STATE_IDS, thread the
config.stateIds into the parsing call sites, and replace the module-level
constant lookups with lookups against the provided config map (also update the
other identical occurrences where STATE_NAME_TO_ID is used so they use the
injected config.stateIds).
- Around line 235-239: The current catch around this.#linear.postComment in
dispatch() is swallowing all errors; change it to only downgrade the known
"unsupported comment" case to a warning and rethrow any other errors so failures
remain fatal. Specifically, inspect the thrown error from
this.#linear.postComment (e.g., instance check for a dedicated
UnsupportedCommentsError or check a unique error property/code such as
error.code === 'UNSUPPORTED_COMMENTS') and if and only if it matches, call
this.#logger.warn?.('[factory] comment writeback skipped', error); otherwise
rethrow the error to propagate failure out of dispatch().

In `@packages/factory-sdk/src/writeback/linear.ts`:
- Around line 128-131: The createIssue flow currently only asserts scope using
payload.team.key (via assertInFactoryScope(scopeIssueFromPayload(...))) but
later accepts payload.teamId alone (used when building the write payload/path),
allowing bypass; update createIssue (and the analogous block around where
payload.teamId is forwarded, e.g., lines handling payload.teamId) to reject
teamId-only payloads or validate the resolved team before writing by resolving
the team object for payload.teamId and running
assertInFactoryScope(scopeIssueFromPayload(resolvedTeamPayload, 'createIssue
payload'), safety, 'createIssue payload') (or throw if teamId cannot be
resolved), ensuring mount.writeFile is only called after the team has been
validated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c14aa7b5-b303-4c63-be18-00cd5e6554cb

📥 Commits

Reviewing files that changed from the base of the PR and between 94fdcc0 and 077802e.

📒 Files selected for processing (13)
  • packages/factory-sdk/src/__tests__/writefile-callsite-invariant.test.ts
  • packages/factory-sdk/src/cli/fleet.test.ts
  • packages/factory-sdk/src/cli/fleet.ts
  • packages/factory-sdk/src/constants/linear.ts
  • packages/factory-sdk/src/mount/relayfile-cloud-mount-client.test.ts
  • packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts
  • packages/factory-sdk/src/orchestrator/factory.test.ts
  • packages/factory-sdk/src/orchestrator/factory.ts
  • packages/factory-sdk/src/ports/mount.ts
  • packages/factory-sdk/src/testing/fakes.ts
  • packages/factory-sdk/src/writeback/linear.ts
  • packages/factory-sdk/src/writeback/slack.ts
  • packages/factory-sdk/src/writeback/writeback.test.ts

Comment on lines +16 to +21
for (const file of await sourceFiles(SDK_ROOT)) {
const rel = relative(SDK_ROOT, file).split('\\').join('/')
if (rel.endsWith('.test.ts') || rel.startsWith('__tests__/')) continue
const source = await readFile(file, 'utf8')
if (!source.includes('mount.writeFile(')) continue
if (!ALLOWED_WRITEBACK_CALLSITES.has(rel)) offenders.push(rel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This invariant is lexical, not structural.

The exact-string scan only catches mount.writeFile( with one formatting shape. mount .writeFile( or newline-separated member access bypass this test while still adding direct write callsites outside the chokepoint. If this invariant is meant to enforce the guardrail, it should parse member-call structure instead of searching for one literal substring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/__tests__/writefile-callsite-invariant.test.ts`
around lines 16 - 21, The test currently uses a fragile substring check for
"mount.writeFile(" which misses variants like "mount .writeFile(" or
newline-separated member access; update the check to parse each file's AST and
detect CallExpressions whose callee is a MemberExpression with an Identifier
named "mount" and a property Identifier "writeFile" (or equivalent computed/name
forms) instead of using source.includes('mount.writeFile('). Modify the loop in
the test (where source is read and ALLOWED_WRITEBACK_CALLSITES and offenders are
referenced) to parse the file (e.g., with `@babel/parser` or TypeScript's parser),
traverse nodes to find such member-call sites, and push rel into offenders when
a matching AST callsite is found.

Comment on lines +285 to +290
const candidates = await mount.listTree('/linear/issues/')
const issuePath = candidates.find((candidate) =>
candidate.startsWith(`/linear/issues/${issueKey}__`) ||
candidate === `/linear/issues/${issueKey}.json`
)
if (!issuePath) return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject ambiguous parent issue matches before authorizing comment drafts.

This find() silently picks the first /linear/issues/${issueKey}* match. When both a canonical issue file and a draft/reconciled variant exist, the scope check can authorize the comment against the wrong parent issue. Please apply the same uniqueness rule used by findIssuePath() here, or explicitly prefer the canonical real-issue path.

Suggested fix
-    const issuePath = candidates.find((candidate) =>
-      candidate.startsWith(`/linear/issues/${issueKey}__`) ||
-      candidate === `/linear/issues/${issueKey}.json`
-    )
-    if (!issuePath) return false
+    const matches = candidates.filter((candidate) =>
+      candidate.startsWith(`/linear/issues/${issueKey}__`) ||
+      candidate === `/linear/issues/${issueKey}.json`
+    )
+    if (matches.length !== 1) return false
+    const [issuePath] = matches
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const candidates = await mount.listTree('/linear/issues/')
const issuePath = candidates.find((candidate) =>
candidate.startsWith(`/linear/issues/${issueKey}__`) ||
candidate === `/linear/issues/${issueKey}.json`
)
if (!issuePath) return false
const candidates = await mount.listTree('/linear/issues/')
const matches = candidates.filter((candidate) =>
candidate.startsWith(`/linear/issues/${issueKey}__`) ||
candidate === `/linear/issues/${issueKey}.json`
)
if (matches.length !== 1) return false
const [issuePath] = matches
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/cli/fleet.ts` around lines 285 - 290, The current
lookup uses candidates.find(...) which can pick the wrong match when both a
canonical file and a draft/reconciled variant exist; update the logic that
computes issuePath (the result of mount.listTree('/linear/issues/') filtered on
issueKey) to reject ambiguous matches or explicitly prefer the canonical path
`/linear/issues/${issueKey}.json`: collect all matches into an array, if there
is exactly one match use it, if multiple matches prefer the exact
`/linear/issues/${issueKey}.json` entry, otherwise return false (mirror the
uniqueness rule used by findIssuePath()) so comment draft authorization cannot
bind to the wrong parent.

Comment on lines 236 to 253
const mapOperationStatus = (
response: OperationStatusResponse,
): 'acked' | 'pending' | 'failed' => {
if (response.status === 'succeeded') return 'acked'
if (response.status === 'succeeded') {
const providerResult = response.providerResult
if (
providerResult &&
providerResult.status === 200 &&
typeof providerResult.externalId === 'string' &&
providerResult.externalId.length > 0
) {
return 'acked'
}
throw new Error(`Writeback provider result incomplete for ${response.path ?? response.opId}: ${providerResultError(response)}`)
}
if (response.status === 'failed' || response.status === 'dead_lettered' || response.status === 'canceled') {
return 'failed'
throw new Error(`Writeback operation failed for ${response.path ?? response.opId}: ${providerResultError(response)}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep terminal confirmWrite() failures aligned with the MountClient contract.

mapOperationStatus() now throws for failed/canceled/dead-lettered ops, but packages/factory-sdk/src/ports/mount.ts and packages/factory-sdk/src/testing/fakes.ts still model confirmWrite() as resolving 'failed'. That makes the cloud client observably incompatible with the interface and the fake, so any caller that branches on the returned status will see different behavior in tests vs. production.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts` around lines
236 - 253, mapOperationStatus currently throws on terminal failures and on a
succeeded response with an incomplete providerResult, which makes the cloud
MountClient behavior diverge from the MountClient contract and the testing fakes
that expect confirmWrite() to resolve to 'failed'; update mapOperationStatus so
that instead of throwing it returns the string 'failed' for these cases (i.e.,
when response.status is 'failed'|'dead_lettered'|'canceled' and when
response.status === 'succeeded' but providerResult is missing/invalid as
determined by providerResultError(response)), leaving successful acked paths
unchanged; ensure confirmWrite callers and the existing fakes remain compatible.

Comment on lines +31 to +37
const STATE_NAME_TO_ID: Record<string, string> = {
'Ready for Agent': LINEAR_STATE_IDS.readyForAgent,
'Agent Implementing': LINEAR_STATE_IDS.agentImplementing,
Implementing: LINEAR_STATE_IDS.agentImplementing,
Done: LINEAR_STATE_IDS.done,
'In Planning': LINEAR_STATE_IDS.inPlanning,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

State-name fallback ignores config.stateIds.

parseLinearIssue() now hard-codes LINEAR_STATE_IDS, but FactoryLoop decides readiness with this.#config.stateIds. If a workspace overrides the workflow IDs in config, a state_name-only record will be parsed to the built-in UUIDs and then skipped as “not ready” even though the configured ready state matches. Please thread the configured state map into parsing instead of using module-level constants here.

Also applies to: 612-613, 742-743

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/orchestrator/factory.ts` around lines 31 - 37, The
STATE_NAME_TO_ID mapping currently uses module-level LINEAR_STATE_IDS, causing
parseLinearIssue to mis-map state_name fallbacks when a workspace overrides IDs;
update parseLinearIssue (and any other parsers referencing STATE_NAME_TO_ID) to
accept and use the configured state map from FactoryLoop (this.#config.stateIds)
instead of LINEAR_STATE_IDS, thread the config.stateIds into the parsing call
sites, and replace the module-level constant lookups with lookups against the
provided config map (also update the other identical occurrences where
STATE_NAME_TO_ID is used so they use the injected config.stateIds).

Comment on lines +235 to +239
try {
await this.#linear.postComment(issue, comment)
} catch (error) {
this.#logger.warn?.('[factory] comment writeback skipped', error)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only downgrade unsupported comment writebacks, not every failure.

This catch now converts timeouts, guard rejections, and provider failures into warnings and still advances the Linear state. That makes dispatch() succeed after a broken writeback path, which is broader than the stated “unsupported comments are best-effort” behavior. Narrow this to the unsupported-comment case (or a dedicated error type) and keep other failures fatal.

Possible fix
         try {
           await this.#linear.postComment(issue, comment)
         } catch (error) {
-          this.#logger.warn?.('[factory] comment writeback skipped', error)
+          if (isUnsupportedCommentWriteback(error)) {
+            this.#logger.warn?.('[factory] comment writeback skipped', error)
+          } else {
+            throw error
+          }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/orchestrator/factory.ts` around lines 235 - 239, The
current catch around this.#linear.postComment in dispatch() is swallowing all
errors; change it to only downgrade the known "unsupported comment" case to a
warning and rethrow any other errors so failures remain fatal. Specifically,
inspect the thrown error from this.#linear.postComment (e.g., instance check for
a dedicated UnsupportedCommentsError or check a unique error property/code such
as error.code === 'UNSUPPORTED_COMMENTS') and if and only if it matches, call
this.#logger.warn?.('[factory] comment writeback skipped', error); otherwise
rethrow the error to propagate failure out of dispatch().

Comment on lines 128 to +131
async createIssue(payload: LinearCreateIssuePayload): Promise<{ path: string }> {
assertInFactoryScope(scopeIssueFromPayload(payload, 'createIssue payload'), safety, 'createIssue payload')
const path = createIssuePath(payload)
await mount.writeFile(path, payload)
await mount.writeFile(path, createIssueWritePayload(payload), { guarded: true })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

teamId now bypasses the factory-scope team check.

Line 129 validates scope from payload.team.key, but Lines 212-217 will still forward payload.teamId when team is absent. That lets a caller send a [factory-e2e] create payload with an arbitrary teamId, pass assertInFactoryScope(...), and create the issue outside the intended AR boundary. Reject teamId-only payloads or validate the resolved team before writing.

Possible fix
     async createIssue(payload: LinearCreateIssuePayload): Promise<{ path: string }> {
+      if (typeof payload.teamId === 'string' && typeof asRecord(payload.team)?.key !== 'string') {
+        throw new Error('Linear createIssue payload must include team.key when teamId is provided')
+      }
       assertInFactoryScope(scopeIssueFromPayload(payload, 'createIssue payload'), safety, 'createIssue payload')
       const path = createIssuePath(payload)
       await mount.writeFile(path, createIssueWritePayload(payload), { guarded: true })

Also applies to: 212-217

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/writeback/linear.ts` around lines 128 - 131, The
createIssue flow currently only asserts scope using payload.team.key (via
assertInFactoryScope(scopeIssueFromPayload(...))) but later accepts
payload.teamId alone (used when building the write payload/path), allowing
bypass; update createIssue (and the analogous block around where payload.teamId
is forwarded, e.g., lines handling payload.teamId) to reject teamId-only
payloads or validate the resolved team before writing by resolving the team
object for payload.teamId and running
assertInFactoryScope(scopeIssueFromPayload(resolvedTeamPayload, 'createIssue
payload'), safety, 'createIssue payload') (or throw if teamId cannot be
resolved), ensuring mount.writeFile is only called after the team has been
validated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/factory-sdk/src/orchestrator/factory.ts (1)

638-645: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate that payload.url reconciles to the same Linear issue.

isRealLinearIssue() only checks that identifier looks like AR-123 and that payload.url is non-empty. That means a synthetic or stale record with identifier: 'AR-123' and any arbitrary URL still passes the new real-issue gate, so runOnce(), dispatch(), and #handleChange() can still act on unreconciled drafts. Parse the URL and require it to point at the same Linear issue key before returning true.

Possible fix
 const isRealLinearIssue = (issue: LinearIssue): boolean => {
   const payload = wrappedPayload(issue.raw)
   const identifier = stringValue(payload.identifier) ?? issue.key
+  const url = stringValue(payload.url)
+  if (!url) return false
+
+  let urlIssueKey: string | undefined
+  try {
+    const parts = new URL(url).pathname.split('/').filter(Boolean)
+    const issueIndex = parts.indexOf('issue')
+    urlIssueKey = issueIndex >= 0 ? parts[issueIndex + 1]?.toUpperCase() : undefined
+  } catch {
+    return false
+  }
+
   return identifier === issue.key &&
     /^[A-Z]+-\d+$/u.test(identifier) &&
-    typeof payload.url === 'string' &&
-    payload.url.length > 0
+    urlIssueKey === identifier
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/orchestrator/factory.ts` around lines 638 - 645,
isRealLinearIssue currently only checks that payload.url is a non-empty string;
update it to parse payload.url (using the URL constructor inside a try/catch)
and extract the Linear issue key from the URL path or fragment (the part that
contains the issue identifier), then require that extracted key equals issue.key
in addition to the existing identifier checks; keep using
wrappedPayload(issue.raw) and stringValue(payload.identifier) but return false
if URL parsing fails or the URL's issue key doesn't match issue.key so only
reconciled Linear links pass.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/factory-sdk/src/orchestrator/factory.ts`:
- Around line 638-645: isRealLinearIssue currently only checks that payload.url
is a non-empty string; update it to parse payload.url (using the URL constructor
inside a try/catch) and extract the Linear issue key from the URL path or
fragment (the part that contains the issue identifier), then require that
extracted key equals issue.key in addition to the existing identifier checks;
keep using wrappedPayload(issue.raw) and stringValue(payload.identifier) but
return false if URL parsing fails or the URL's issue key doesn't match issue.key
so only reconciled Linear links pass.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fec7ad9b-057e-4a5e-9dc7-de4018875f4c

📥 Commits

Reviewing files that changed from the base of the PR and between 077802e and 82e511f.

📒 Files selected for processing (5)
  • packages/factory-sdk/src/mount/relayfile-cloud-mount-client.ts
  • packages/factory-sdk/src/orchestrator/factory.ts
  • packages/factory-sdk/src/ports/mount.ts
  • packages/factory-sdk/src/writeback/linear.ts
  • packages/factory-sdk/src/writeback/writeback.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/factory-sdk/src/ports/mount.ts

@kjgbot kjgbot merged commit 3693fd0 into main Jun 12, 2026
5 checks passed
@kjgbot kjgbot deleted the factory-sdk/w10-writeback-payload branch June 12, 2026 10:30
kjgbot added a commit that referenced this pull request Jun 12, 2026
…letes (D7, #244)

deleteFile default-deny on provider paths (/linear/**,/slack/**); allow ONLY a this-client unreconciled orphan draft (tracked op terminally failed/dead_lettered/canceled + no providerResult.externalId + no url/real-key + injected isAllowedDelete) — race/unknown-op/succeeded-with-externalId all REFUSE. Structural invariant extended to mount.deleteFile. Reviewed @a24c484 (w9-review V0, all clauses mutation-checked; post-rebase verified no resurrection of the pre-#243 fail-open) + factory-verify live V1 GREEN (mount.deleteFile(AR-133)→REFUSED at the linked check, canary intact).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant