diff --git a/docs/linear-integration/README.md b/docs/linear-integration/README.md new file mode 100644 index 00000000..6ac6d86f --- /dev/null +++ b/docs/linear-integration/README.md @@ -0,0 +1,28 @@ +# Linear Integration — Specification Index + +Six directions for integrating Linear as a governance surface for the +Function Factory. Directions 1, 3, and 6 collapse into `SPEC-LINEAR-SYNC-SERVICE-001`; +the remaining three have dedicated specs. + +| Direction | Description | Spec | +|-----------|-------------|------| +| 1 | WorkGraph atoms as Linear issues (P1/P2 atom + trace state projections) | SPEC-LINEAR-SYNC-SERVICE-001 | +| 2 | Linear as We-layer Disposition Event surface (ff-linear-bridge) | SPEC-FF-LINEAR-BRIDGE-001 | +| 3 | Divergence → Linear issue with A9 elucidation (P3 divergence projection) | SPEC-LINEAR-SYNC-SERVICE-001 | +| 4 | Linear cycle as governance sprint cadence (CycleAwarenessService) | SPEC-FF-CYCLE-HEALTH-001 | +| 5 | Factory commit tracing via FACTORY_* env vars → git trailers → SHA back to Linear | SPEC-FF-COMMIT-TRACING-001 | +| 6 | Factory Health as living Linear document (P4 health document) | SPEC-FF-CYCLE-HEALTH-001 | + +## Key architectural finding + +Linear is already the We-layer during Bootstrap. The bridge is a +translation layer; the WeOps Console (SPEC-WEOPS-CONSOLE-001) is the +native surface. The `WEOPS_SIGNING_KEY` is shared between the bridge and +the Console — no gateway update required during Phase 1→3 migration. + +## Implementation order + +1. `SPEC-LINEAR-SYNC-SERVICE-001` — P1 atom projection (unblocks WEO-7/8/9) +2. `SPEC-FF-LINEAR-BRIDGE-001` — bridge webhook handler (unblocks human Dispositions) +3. `SPEC-FF-COMMIT-TRACING-001` — commit tracing (closes lineage loop) +4. `SPEC-FF-CYCLE-HEALTH-001` — cycle cadence + health document (observability) diff --git a/docs/linear-integration/SPEC-FF-COMMIT-TRACING-001.md b/docs/linear-integration/SPEC-FF-COMMIT-TRACING-001.md new file mode 100644 index 00000000..b3bb7c96 --- /dev/null +++ b/docs/linear-integration/SPEC-FF-COMMIT-TRACING-001.md @@ -0,0 +1,454 @@ +# Commit Tracing Specification +**ID**: SPEC-FF-COMMIT-TRACING-001 +**Status**: Draft — pending Architect sign-off +**Date**: 2026-06-05 +**Layer**: I-layer runtime — lineage closure +**Touches**: `packages/conducting-agent/src/conducting-agent.ts`, + `harness/hooks/pre_tool_call.ts`, + `harness/hooks/post_execution.ts`, + `packages/linear-sync/src/` (new endpoint) +**No new package required** + +--- + +## 0. Conceptual Preamble + +### 0.1 The lineage gap this closes + +The current lineage chain runs: + +``` +PRD-* → WG-* → AtomDirective → CommitBead (ArangoDB) → BuildOutcomeBead +``` + +But the git commit produced by the Conducting Agent's Gas City session +is orphaned from this chain. Nothing connects the commit SHA to the +`directiveId` that authorized it. A developer looking at a commit sees +code; they cannot determine which Specification governed it, which +invariants were in scope at the time, or whether it was produced under +a favorable Fidelity Verdict. + +Commit tracing closes this gap by: + +1. Injecting lineage context into the Gas City session as env vars + (so the commit message can carry it) +2. Intercepting every `git commit` call in `pre_tool_call.ts` to append + Factory trailer lines to the commit message +3. Reading the produced commit SHA in `post_execution.ts` and writing + it back to the atom's Linear issue and to the CommitBead in ArangoDB +4. Adding a new `commitShaVerificationDetector` INV-* that enforces + tracing as a governance invariant for git-permitted atoms + +### 0.2 The full closed chain + +After this spec is implemented: + +``` +PRD-* ──▶ WG-* ──▶ AtomDirective ──▶ Linear issue (WEO-N) + │ + ▼ + CommitBead ──▶ BuildOutcomeBead + │ + ▼ + git commit (SHA: abc1234) + │ + ├──▶ Linear issue link (via WEO-N in trailer) + └──▶ CommitBead.commitSha (ArangoDB) +``` + +Every artifact in this chain is traversable in both directions via the +`factory-lineage` ArangoDB graph. + +--- + +## 1. Env Var Injection (AtomDirective → Gas City session) + +### 1.1 Change: Mediation Agent DO compile step + +When compiling `AtomDirective[]` from a WorkGraph on `/commission`, the +Mediation Agent resolves the Linear issue binding for each atom (via +`linear_bindings` ArangoDB collection) and injects it into `envVars`: + +```typescript +// Added to AtomDirective.envVars during compile step +// Only injected if a LinearBinding exists for this directiveId +{ + FACTORY_DIRECTIVE_ID: directive.directiveId, + FACTORY_ATOM_REF: directive.atomRef, + FACTORY_WORK_GRAPH_VERSION: directive.workGraphVersion, + FACTORY_REPO_ID: directive.repoId, + FACTORY_POLICY_BEAD_ID: policyBeadId, + FACTORY_LINEAR_ISSUE_ID: linearBinding?.linearIssueId ?? '', +} +``` + +If no `LinearBinding` exists for a given `directiveId` at compile time +(e.g., `LinearSyncService` has not yet created the issue), the +`FACTORY_LINEAR_ISSUE_ID` field is set to empty string. The hook in +`pre_tool_call.ts` omits the Linear trailer line when it is empty. + +The Linear binding will exist for all atoms in normal operation because +`LinearSyncService` runs on the same alarm that triggers the compile +step. The empty-string fallback handles the edge case where the sync +service has not yet run. + +### 1.2 No schema change to AtomDirective + +`AtomDirective.envVars` is already typed as +`Record` with no fixed keys. The injected fields are +conventional, not schema-enforced. The `FACTORY_*` prefix is reserved +by convention — no non-Factory env var should use it. This is documented +in `AGENTS.md` as a constraint on human-authored env var keys. + +--- + +## 2. pre_tool_call.ts Hook Changes + +### 2.1 Current responsibility + +The existing hook enforces the `permittedTools` allowlist — it rejects +any tool call not in `directive.permittedTools`. + +### 2.2 New responsibility: git commit interception + +When a `git commit` call is intercepted (i.e., the tool call is a shell +command that invokes `git commit`), the hook rewrites the commit message +to append Factory trailer lines before passing the call to Gas City. + +**Detection:** the hook matches on: +```typescript +const isGitCommit = (toolCall: ToolCall): boolean => + toolCall.tool === 'shell' && + /\bgit\s+commit\b/.test(toolCall.input.command) +``` + +**Rewrite:** the hook extracts the `-m` message from the command and +appends trailers: + +```typescript +function appendFactoryTrailers( + command: string, + env: Record +): string { + // Extract existing message (handles -m "..." and -m '...' and multiline) + const messageMatch = command.match(/-m\s+(['"])([\s\S]*?)\1/) + if (!messageMatch) return command // non-standard commit form; pass through + + const originalMessage = messageMatch[2] + const trailers: string[] = [] + + if (env.FACTORY_DIRECTIVE_ID) + trailers.push(`Factory-Directive: ${env.FACTORY_DIRECTIVE_ID}`) + if (env.FACTORY_WORK_GRAPH_VERSION) + trailers.push(`Factory-WorkGraph: WG-${env.FACTORY_REPO_ID}@${env.FACTORY_WORK_GRAPH_VERSION}`) + if (env.FACTORY_LINEAR_ISSUE_ID) + trailers.push(`Factory-Linear: ${env.FACTORY_LINEAR_ISSUE_ID}`) + if (env.FACTORY_POLICY_BEAD_ID) + trailers.push(`Factory-PolicyBead: ${env.FACTORY_POLICY_BEAD_ID}`) + + if (trailers.length === 0) return command // no env vars injected; pass through + + // Git trailer format: blank line before trailers + const newMessage = `${originalMessage}\n\n${trailers.join('\n')}` + return command.replace( + /-m\s+(['"])([\s\S]*?)\1/, + `-m "${newMessage}"` + ) +} +``` + +**Why not `--trailer` flag:** `git commit --trailer` was added in Git +2.33. Gas City sandboxes may run older Git versions. String injection +into `-m` is universally compatible. + +**Passthrough on non-match:** if the `git commit` command cannot be +parsed (e.g., uses `--file`, `--amend` without `-m`, or is part of a +script), the hook passes it through unmodified and logs a +`CommitTrailerSkipped` event to the DO event log. The tracing invariant +detector (§4) will fire as advisory on this atom. + +### 2.3 Hook signature (updated) + +```typescript +// harness/hooks/pre_tool_call.ts +export async function preToolCall( + toolCall: ToolCall, + context: { + directive: AtomDirective + sessionEnv: Record + } +): Promise<{ allowed: boolean; modifiedToolCall?: ToolCall; reason?: string }> { + // 1. Permitted tools check (existing) + if (!context.directive.permittedTools.includes(toolCall.tool)) { + return { allowed: false, reason: `tool-not-permitted: ${toolCall.tool}` } + } + + // 2. git commit interception (new) + if (isGitCommit(toolCall) && context.directive.permittedTools.includes('git')) { + const rewrittenCommand = appendFactoryTrailers( + toolCall.input.command, + context.sessionEnv + ) + if (rewrittenCommand !== toolCall.input.command) { + return { + allowed: true, + modifiedToolCall: { + ...toolCall, + input: { ...toolCall.input, command: rewrittenCommand } + } + } + } + // Passthrough (couldn't parse message) + return { allowed: true } + } + + return { allowed: true } +} +``` + +--- + +## 3. post_execution.ts Hook Changes + +### 3.1 Current responsibility + +The existing hook truncates `rawOutput` to 4KB before it is reported to +the Mediation Agent, storing the full output in R2. + +### 3.2 New responsibility: commit SHA extraction + +After a successful atom execution (Gas City session reports +`outcome: success`), the hook reads the git log to extract the SHA of +the most recent commit produced by this session. + +**Extraction:** + +```typescript +async function extractCommitSha( + sessionResult: GasCitySessionResponse, + directive: AtomDirective, + gasCityUrl: string +): Promise { + // Only attempt on atoms with git permission + if (!directive.permittedTools.includes('git')) return undefined + + // Run git log --oneline -1 in the session's working dir + // via a lightweight follow-up shell call to Gas City + const logResult = await runFollowUpCommand( + `git -C ${directive.workingDir} log --oneline -1 --format=%H`, + gasCityUrl, + directive + ) + + if (!logResult || logResult.exitCode !== 0) return undefined + + const sha = logResult.stdout.trim() + // Validate: 40-char hex SHA + return /^[0-9a-f]{40}$/.test(sha) ? sha : undefined +} +``` + +This follow-up command runs in the same Gas City session before it is +closed — a lightweight read-only operation that does not constitute a +second atom execution. The session is not billed as a new execution. + +### 3.3 SHA propagation + +Once the SHA is extracted, it is: + +1. Included in the `ConductingAgentTraceFragment` as `commitSha`: + +```typescript +// Extended TraceFragment (additive — no breaking change) +type ConductingAgentTraceFragment = { + // ... existing fields ... + commitSha?: string // undefined if atom has no git permission or no commit produced +} +``` + +2. The Mediation Agent DO's `/trace` handler writes it to the + `TraceEvent` payload (no schema change needed — `TracePayload` + already accepts the full `ConductingAgentTraceFragment`). + +3. On the next Bead flush, the `CommitBead` written to ArangoDB is + updated with `commitSha`: + +```typescript +// CommitBead addition +type CommitBead = BeadEnvelope & { + beadType: 'CommitBead' + directiveId: string + atomRef: string + workGraphVersion: string + sessionId: string + outcome: string + commitSha?: string // new field — populated by post_execution hook +} +``` + +4. `LinearSyncService` receives the SHA via a new `POST /sync/commit-sha` + endpoint (see §5) and posts it as a comment on the atom's Linear issue. + +### 3.4 Hook signature (updated) + +```typescript +// harness/hooks/post_execution.ts +export async function postExecution( + sessionResult: GasCitySessionResponse, + context: { + directive: AtomDirective + executionId: string + gasCityUrl: string + } +): Promise<{ + rawOutput: string // truncated to 4KB + sandboxOutputRef?: string // presigned R2 URL + commitSha?: string // new +}> { + // 1. Truncate output (existing) + const rawOutput = sessionResult.stdout.slice(0, 4096) + const sandboxOutputRef = sessionResult.stdout.length > 4096 + ? await storeFullOutput(sessionResult.stdout, context.directive.directiveId) + : undefined + + // 2. Extract commit SHA (new) + const commitSha = sessionResult.outcome === 'success' + ? await extractCommitSha(sessionResult, context.directive, context.gasCityUrl) + : undefined + + return { + ...(sandboxOutputRef !== undefined ? { sandboxOutputRef } : {}), + rawOutput, + ...(commitSha !== undefined ? { commitSha } : {}), + } +} +``` + +--- + +## 4. Commit Tracing Invariant Detector + +A new INV-* detector spec enforces that every atom with `git` tool +permission produces a traceable commit. + +```yaml +# specs/invariants/INV-COMMIT-TRACE-001.yaml +id: INV-COMMIT-TRACE-001 +name: CommitTraceability +severity: warning +statement: > + Every atom with 'git' in permittedTools that produces outcome: success + must have a non-null commitSha in its TraceFragment. +detector: + type: trace-field-check + condition: + if: + permittedTools_contains: git + outcome: success + then: + field: commitSha + must_be: non-null +failure_action: > + Advisory Divergence. Hypothesis formation: the atom likely made a git + operation without committing (e.g., git add only), or the commit message + was non-standard and the trailer could not be injected. +``` + +This INV-* spec is `severity: warning` → `advisory` Divergence per the +severity classification policy (DECISIONS N+2). It does not block +execution — it surfaces a traceability gap for Hypothesis formation. + +The cases where this fires legitimately (atoms that manipulate git +without committing, e.g., `git add` staging steps) should have +`read-only` or `shell` tool permission, not `git`. The detector fires +as a signal that the atom's tool permissions may be over-broad. + +--- + +## 5. LinearSyncService: new POST /sync/commit-sha endpoint + +### 5.1 Input + +```typescript +type CommitShaSyncRequest = { + directiveId: string + commitSha: string + workGraphVersion: string + repoId: string + durationMs: number + attemptNumber: number +} +``` + +### 5.2 Behavior + +1. Look up `LinearBinding` for `directiveId` +2. If no binding: log and return (issue may not have been created yet; + SHA will be visible in ArangoDB CommitBead regardless) +3. Post comment on atom issue: + +``` +✅ Atom completed successfully on attempt {attemptNumber}. +Duration: {durationMs}ms + +**Commit**: `{commitSha}` +[View on GitHub](https://github.com/Wescome/function-factory/commit/{commitSha}) +``` + +4. The GitHub link is constructed from a `GITHUB_REPO_URL` env var + in the sync service. If absent: link is omitted. + +### 5.3 Caller + +The Conducting Agent Worker calls this endpoint after the `TraceFragment` +is reported to the Mediation Agent, if `commitSha` is present: + +```typescript +// In conducting-agent.ts, after reportTrace() +if (trace.commitSha) { + await fetch(`${env.LINEAR_SYNC_URL}/sync/commit-sha`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + directiveId: directive.directiveId, + commitSha: trace.commitSha, + workGraphVersion: directive.workGraphVersion, + repoId: directive.repoId, + durationMs: trace.durationMs, + attemptNumber: trace.attemptNumber, + }), + }) + // Fire-and-forget: Linear failure does not affect Factory governance +} +``` + +--- + +## 6. Summary of Changes by File + +| File | Change type | Description | +|------|------------|-------------| +| `packages/mediation-agent/src/mediation-agent-do.ts` | Additive | Inject `FACTORY_*` env vars during AtomDirective compile step, after resolving `linear_bindings` | +| `harness/hooks/pre_tool_call.ts` | Additive | `git commit` interception + trailer injection; passthrough on non-parseable commands | +| `harness/hooks/post_execution.ts` | Additive | Commit SHA extraction via follow-up `git log` call; included in returned payload | +| `packages/conducting-agent/src/types.ts` | Additive | `commitSha?: string` on `ConductingAgentTraceFragment` | +| `packages/conducting-agent/src/conducting-agent.ts` | Additive | Fire-and-forget call to `POST /sync/commit-sha` when `commitSha` is present | +| `packages/linear-sync/src/index.ts` | Additive | Route `POST /sync/commit-sha` to new handler | +| `packages/linear-sync/src/commit-sha-sync.ts` | New file | Handler for `POST /sync/commit-sha` | +| `packages/schemas/src/atom-directive.ts` | No change | `envVars: Record` already accepts `FACTORY_*` keys | +| `specs/invariants/INV-COMMIT-TRACE-001.yaml` | New file | Warning-severity detector for commit traceability | + +No new packages. No breaking changes to existing interfaces. All changes +are additive. + +--- + +## 7. Open Items + +| Item | Owner | Blocking | +|------|-------|---------| +| `runFollowUpCommand()` implementation — lightweight Gas City shell call after session completes, before close | Engineering | Yes — needed for SHA extraction | +| `GITHUB_REPO_URL` env var in `linear-sync` — for commit deep-link construction | Engineering | No — link omitted if absent | +| `INV-COMMIT-TRACE-001` registration in compiler — invariant must be associated with relevant WorkGraph atom types at authoring time | Engineering | No — can be manually registered for v1 | +| `CommitTrailerSkipped` event type addition to Mediation Agent DO event log types | Engineering | No — advisory only; non-blocking | +| Multi-commit atoms — if an atom produces multiple commits (e.g., a fixup loop), `git log --oneline -1` captures only the latest. Whether to capture all SHAs is a design question | Architect | No — single SHA is sufficient for v1 lineage tracing | diff --git a/docs/linear-integration/SPEC-FF-CYCLE-HEALTH-001.md b/docs/linear-integration/SPEC-FF-CYCLE-HEALTH-001.md new file mode 100644 index 00000000..e7c47e0a --- /dev/null +++ b/docs/linear-integration/SPEC-FF-CYCLE-HEALTH-001.md @@ -0,0 +1,414 @@ +# CycleAwarenessService + Health Document Specification +**ID**: SPEC-FF-CYCLE-HEALTH-001 +**Status**: Draft — pending Architect sign-off +**Date**: 2026-06-05 +**Layer**: I-layer runtime — We-layer cadence + observability +**Packages**: `packages/linear-sync/src/` (health document — P4 already + specified in SPEC-LINEAR-SYNC-SERVICE-001 §5, implemented here), + `packages/commissioning-agent/src/` (cycle awareness) +**No new package required** + +--- + +## 0. Why these two are one spec + +Direction 4 (cycle cadence) and Direction 6 (health document) share one +dependency: both need to read the current Linear cycle. The +`CycleAwarenessService` is the shared component. Speccing them together +avoids duplicating the cycle API access pattern. + +--- + +## 1. CycleAwarenessService + +### 1.1 What it does + +`CycleAwarenessService` is a lightweight read-only service that: + +1. Reads the current active cycle for the WeOps team from Linear +2. Caches the result in CF KV with a 1-hour TTL (cycles don't change + mid-day; re-fetching every poll would be wasteful) +3. Returns a `CycleContext` to callers + +It is not a Worker — it is a module imported by the Commissioning Agent +Worker and the LinearSyncService Worker. Both call it; neither owns it. + +### 1.2 CycleContext + +```typescript +// packages/linear-sync/src/cycle-awareness.ts +// Also imported by packages/commissioning-agent/src/ + +export type CycleContext = { + cycleId: string + cycleName: string // e.g. "Sprint 14" + startsAt: string // ISO 8601 + endsAt: string // ISO 8601 + daysRemaining: number // floored integer; 0 = last day + isLastTwoDays: boolean // daysRemaining <= 2 + isCycleEnd: boolean // daysRemaining === 0 +} + +export async function getCycleContext( + teamId: string, + kv: KVNamespace, + linearApiKey: string +): Promise { + // 1. Check KV cache + const cached = await kv.get(`cycle-context:${teamId}`) + if (cached) return JSON.parse(cached) as CycleContext + + // 2. Fetch from Linear + const result = await fetchActiveCycle(teamId, linearApiKey) + if (!result) return null + + const now = Date.now() + const endsAt = new Date(result.endsAt).getTime() + const daysRemaining = Math.floor((endsAt - now) / (1000 * 60 * 60 * 24)) + + const context: CycleContext = { + cycleId: result.id, + cycleName: result.name, + startsAt: result.startsAt, + endsAt: result.endsAt, + daysRemaining, + isLastTwoDays: daysRemaining <= 2, + isCycleEnd: daysRemaining === 0, + } + + // 3. Cache for 1 hour + await kv.put(`cycle-context:${teamId}`, JSON.stringify(context), { + expirationTtl: 3600 + }) + + return context +} + +async function fetchActiveCycle( + teamId: string, + apiKey: string +): Promise<{ id: string; name: string; startsAt: string; endsAt: string } | null> { + const query = ` + query ActiveCycle($teamId: String!) { + team(id: $teamId) { + activeCycle { + id + name + startsAt + endsAt + } + } + } + ` + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': apiKey, + }, + body: JSON.stringify({ query, variables: { teamId } }), + }) + + const data = await response.json() as { + data?: { team?: { activeCycle?: { id: string; name: string; startsAt: string; endsAt: string } } } + } + return data.data?.team?.activeCycle ?? null +} +``` + +### 1.3 No cycle → no deferral + +If no active cycle exists in Linear (`activeCycle` is null), advisory +Hypotheses are surfaced immediately rather than deferred. The cadence +discipline requires cycles to be configured. If they aren't, the system +defaults to always-on advisory surfacing — the less disciplined but +not broken behavior. + +--- + +## 2. Direction 4 — Cycle-Based Advisory Surfacing + +### 2.1 Where it lives + +In the Commissioning Agent's polling loop, which runs as a CF Workflow +or Cron Trigger (to be wired per open item in SPEC-COMMISSIONING- +AGENT-001). The cycle check runs on every poll cycle alongside the +standard Divergence classification. + +### 2.2 Polling loop addition + +```typescript +// packages/commissioning-agent/src/commissioning-agent.ts +// Added to the polling loop (§3.2 of SPEC-COMMISSIONING-AGENT-001) + +async function runPollCycle(repoId: string, env: Env): Promise { + // ... existing: get Mediation Agent state, classify Divergences ... + + const cycle = await getCycleContext(env.LINEAR_TEAM_ID, env.FACTORY_LINEAR_KV, env.LINEAR_API_KEY) + + // Advisory Hypotheses queued in ArangoDB but not yet surfaced to Linear + const pendingAdvisories = await loadPendingAdvisoryHypotheses(repoId, env) + + for (const hyp of pendingAdvisories) { + if (!cycle || cycle.isLastTwoDays) { + // Surface now: create/update Linear issue with factory:cycle-boundary label + await surfaceAdvisoryHypothesis(hyp, cycle, env) + } + // else: leave in ArangoDB queue; surface at cycle boundary + } + + // Cycle end: reconciliation + if (cycle?.isCycleEnd) { + await runCycleReconciliation(repoId, cycle, env) + } +} +``` + +### 2.3 Advisory surfacing + +When `cycle.isLastTwoDays || !cycle`: + +```typescript +async function surfaceAdvisoryHypothesis( + hyp: Hypothesis, + cycle: CycleContext | null, + env: Env +): Promise { + // Check if already has a Linear issue + const binding = await getLinearBinding(hyp.hypothesisId, env) + if (binding) return // already surfaced + + // Create Linear issue for this advisory Hypothesis + await fetch(`${env.LINEAR_SYNC_URL}/sync/advisory-hypothesis`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + hypothesisId: hyp.hypothesisId, + repoId: hyp.repoId, + divergenceId: hyp.divergenceId, + hypothesisContent: hyp.content, + cycleId: cycle?.cycleId, + cycleName: cycle?.cycleName, + surfacedBecause: cycle?.isLastTwoDays ? 'cycle-boundary' : 'no-active-cycle', + }), + }) + + // Mark as surfaced in ArangoDB + await markHypothesisSurfaced(hyp.hypothesisId, env) +} +``` + +### 2.4 New LinearSyncService endpoint: POST /sync/advisory-hypothesis + +```typescript +type AdvisoryHypothesisSyncRequest = { + hypothesisId: string // HYP-* ref + repoId: string + divergenceId: string // DIV-* ref + hypothesisContent: string + cycleId?: string + cycleName?: string + surfacedBecause: 'cycle-boundary' | 'no-active-cycle' +} +``` + +Creates a Linear issue: + +```typescript +{ + title: `[ADVISORY] ${hypothesisId} — ${repoId}`, + teamId: LINEAR_TEAM_ID, + projectId: FACTORY_PROJECT_ID, + milestoneId: cycle?.cycleId, // assigns to current cycle if available + stateId: BACKLOG_STATE_ID, + labelIds: [LABEL_FACTORY_ADVISORY, LABEL_FACTORY_CYCLE_BOUNDARY], + description: buildAdvisoryDescription(request), +} +``` + +**Description template:** + +```markdown +## Advisory Hypothesis +**Hypothesis**: {hypothesisId} +**Repo**: `{repoId}` +**Surfaced**: {surfacedBecause === 'cycle-boundary' ? `cycle boundary ({cycleName})` : 'no active cycle'} + +## Hypothesis Content +{hypothesisContent} + +## Linked Divergence +See divergence issue for evidence and Elucidation Artifact. + +## Action +This is an advisory item — execution is not blocked. Review and decide: +- Accept the hypothesis and propose an Amendment +- Reject the hypothesis (close this issue with rationale) +- Defer to next cycle (add `factory:carried-over` label) + +## Identity +- Hypothesis ID: `{hypothesisId}` +- Divergence ID: `{divergenceId}` +``` + +### 2.5 Cycle reconciliation + +At `isCycleEnd`, the Commissioning Agent runs a reconciliation for the +closing cycle: + +```typescript +async function runCycleReconciliation( + repoId: string, + cycle: CycleContext, + env: Env +): Promise { + // 1. Find all advisory issues in this cycle that are not Done/Cancelled + const openAdvisories = await getOpenCycleAdvisories(cycle.cycleId, env) + + for (const issue of openAdvisories) { + // Add factory:carried-over label + await linearClient.addLabel(issue.linearIssueInternalId, LABEL_CARRIED_OVER) + } + + // 2. Produce VCR for cycle close + await writeVCR({ + dispositionEventType: 'cycle-close', + repoId, + verdict: 'favorable', + verdictSource: 'cycle-reconciliation', + linkedArtifacts: openAdvisories.map(i => i.hypothesisId), + metadata: { + cycleId: cycle.cycleId, + openAdvisoryCount: openAdvisories.length, + carriedOverCount: openAdvisories.length, + }, + }, env) + + // 3. If open advisories recurred from previous cycle: + // check if any HYP-* has been carried over >= 2 consecutive cycles + const recurringHypotheses = await findRecurringAdvisories(repoId, 2, env) + if (recurringHypotheses.length > 0) { + // Surface to Architect Agent as a cross-cycle anomaly signal + await notifyArchitectAgent(recurringHypotheses, env) + } +} +``` + +**Recurring advisory escalation to Architect Agent.** A Hypothesis +that has been carried over across two consecutive cycles without +resolution is a signal that the advisory is either: (a) a systematic +pattern requiring pipeline configuration changes (D4), or (b) a +Hypothesis that the team has implicitly decided not to act on, which +should be formally rejected rather than perpetually deferred. + +The Architect Agent receives the recurring advisory list and adds it to +its next D4 anomaly scan input. This closes the feedback loop between +the We-layer governance cadence and the Factory's pipeline configuration. + +--- + +## 3. Direction 6 — Health Document Implementation + +### 3.1 Where P4 lives + +`packages/linear-sync/src/p4-health-document.ts` — already specified +in SPEC-LINEAR-SYNC-SERVICE-001 §5. This section covers only the +implementation additions needed to wire it to the cycle context. + +### 3.2 Cycle context in the health document + +The health document gains one section when cycle context is available: + +```markdown +## Current Cycle +**{cycleName}** — {daysRemaining} days remaining +{isLastTwoDays ? '⚠️ Cycle boundary approaching — advisory items will be surfaced' : ''} + +Advisory items queued (not yet surfaced): {queuedAdvisoryCount} +Advisory items surfaced this cycle: {surfacedAdvisoryCount} +Carried over from last cycle: {carriedOverCount} +``` + +### 3.3 HealthSyncRequest addition + +```typescript +// Addition to HealthSyncRequest in SPEC-LINEAR-SYNC-SERVICE-001 §5.3 +type HealthSyncRequest = { + // ... existing fields ... + cycleContext?: CycleContext // null if no active cycle + advisoryMetrics: { + queued: number // in ArangoDB, not yet surfaced + surfacedThisCycle: number // surfaced in current cycle + carriedOver: number // carried over from last cycle + } +} +``` + +### 3.4 Daily history snapshot + +The `p4-health-document.ts` handler already specifies appending a +daily snapshot to `Factory Health — History` at midnight. The snapshot +format is identical to the live document but timestamped and immutable. +The midnight trigger is a CF Cron Trigger on the `linear-sync` Worker: + +```toml +# wrangler.toml addition for linear-sync worker +[[triggers.crons]] +cron = "0 0 * * *" # midnight UTC daily +``` + +The cron handler reads the current `HealthSyncRequest` from ArangoDB +(the Architect Agent always writes the latest health state there on each +scan) and appends the snapshot. + +--- + +## 4. Summary of Changes by File + +| File | Change type | Description | +|------|------------|-------------| +| `packages/linear-sync/src/cycle-awareness.ts` | New file | `CycleAwarenessService` — reads Linear active cycle, caches in KV | +| `packages/linear-sync/src/p4-health-document.ts` | Additive | Add cycle section to live document; daily history snapshot via cron | +| `packages/linear-sync/src/index.ts` | Additive | Route `POST /sync/advisory-hypothesis`; add cron handler | +| `packages/linear-sync/src/advisory-hypothesis-sync.ts` | New file | Handler for advisory Hypothesis issue creation | +| `packages/commissioning-agent/src/commissioning-agent.ts` | Additive | Cycle-aware advisory surfacing in polling loop; cycle reconciliation | +| `wrangler.toml` (linear-sync) | Additive | Midnight cron trigger for daily health snapshot | + +--- + +## 5. Environment Bindings (additions) + +**linear-sync Worker** (additions to existing bindings in +SPEC-LINEAR-SYNC-SERVICE-001 §11): + +```typescript +{ + LINEAR_TEAM_ID: string // already present + FACTORY_LINEAR_KV: KVNamespace // already present; also used for cycle cache + // No new bindings needed +} +``` + +**commissioning-agent Worker** (additions to existing bindings in +SPEC-COMMISSIONING-AGENT-001 §8): + +```typescript +{ + LINEAR_TEAM_ID: string // new + LINEAR_API_KEY: string // new — read-only; only for cycle query + LINEAR_SYNC_URL: string // new — URL of linear-sync Worker + FACTORY_LINEAR_KV: KVNamespace // new — for cycle context cache +} +``` + +--- + +## 6. Open Items + +| Item | Owner | Blocking | +|------|-------|---------| +| Cron trigger for polling loop — Commissioning Agent needs a CF Workflow or Cron; the cycle check runs inside the existing polling loop so no new cron is needed for Direction 4 specifically | Engineering | No | +| `getOpenCycleAdvisories()` — requires Linear API query for issues in a specific cycle with specific labels; needs the Linear GraphQL `cycle.issues` query | Engineering | No | +| `findRecurringAdvisories()` — ArangoDB query comparing HYP-* `surfacedAt` cycle IDs across two consecutive cycles; query pattern TBD | Engineering | No | +| Architect Agent notification endpoint for recurring advisories — `POST /recurring-advisories` not yet in SPEC-ARCHITECT-AGENT-DO-001 | Architect | No — Architect Agent can receive via existing anomaly scan ArangoDB reads | +| Linear cycle configuration — WeOps team must have cycles enabled and configured in Linear; this is a manual setup step | Wes | No | diff --git a/docs/linear-integration/SPEC-FF-LINEAR-BRIDGE-001.md b/docs/linear-integration/SPEC-FF-LINEAR-BRIDGE-001.md new file mode 100644 index 00000000..03efa2bb --- /dev/null +++ b/docs/linear-integration/SPEC-FF-LINEAR-BRIDGE-001.md @@ -0,0 +1,707 @@ +# ff-linear-bridge Specification +**ID**: SPEC-FF-LINEAR-BRIDGE-001 +**Status**: Draft — pending Architect sign-off +**Date**: 2026-06-05 +**Layer**: I-layer / We-layer boundary — Linear webhook handler +**Package**: `@factory/linear-bridge` +**Depends on**: `@factory/schemas`, SPEC-WEOPS-GATEWAY-BOUNDARY-001, + SPEC-LINEAR-SYNC-SERVICE-001, Linear webhook API + +--- + +## 0. Conceptual Preamble + +### 0.1 What ff-linear-bridge IS + +`ff-linear-bridge` is the We-layer governance console adapter. It is the +component that converts human Disposition Events performed in Linear into +signed gateway signals that the Factory's I-layer can act on. + +In the full governance loop: + +``` +Factory I-layer (auto-suspend, Amendment failure, CRP failure) + ↓ escalateToWeLayer() → LinearSyncService creates escalation issue +Linear issue (factory:escalation label) + ↓ human posts DISPOSITION comment + adds factory:disposition-recorded +Linear webhook → ff-linear-bridge + ↓ parses disposition, validates authority, writes ELC-* to ArangoDB + ↓ issues WeOpsDispositionToken (signed JWT) + ↓ POST to WeOps Gateway /signals +WeOps Gateway + ↓ validates token, routes to target I-layer agent +Factory I-layer resumes +``` + +`ff-linear-bridge` occupies exactly one step in this chain: it is the +translation layer between a human act (posting a comment in Linear) and +a machine-recognizable governance signal (a signed JWT + typed payload). + +### 0.2 Ontological significance + +The bridge is where the Disposition Event actually occurs in the formal +sense. Specifically: + +- The human posting a `DISPOSITION:` comment is the authority-bound + selection from a Candidate Set (ontology §4B.3) +- The bridge's parsing of `candidatesConsidered` and `rejectedOptions` + produces the Elucidation Artifact (A9 obligation) +- The JWT it issues is the authority binding — it records which human + authorized the disposition and under which token scope +- The ELC-* artifact written to ArangoDB is the permanent governance + record of the Disposition Event + +A disposition comment without an ELC-* artifact is a degenerate +disposition — it satisfies the operational requirement but fails the +learning requirement (A9). The bridge enforces A9 mechanically: it +refuses to issue a token until the Elucidation Artifact is written. + +### 0.3 Authority model + +The bridge does not trust all Linear users equally. Authority is derived +from two sources: + +1. **Linear team membership**: the commenter must be a member of the + WeOps team in Linear. Non-members' comments are ignored. + +2. **Token scope**: different escalation types require different scopes. + `we-layer:override` scope requires two-person approval — two distinct + team members must post `APPROVED` before the override token is issued. + +The bridge maintains an `AuthorityRegistry` in CF KV that maps Linear +user IDs to their permitted token scopes. + +--- + +## 1. Webhook Setup + +### 1.1 Linear webhook configuration + +Linear must be configured with a webhook pointing to the bridge: + +``` +URL: https://ff-linear-bridge.koales.workers.dev/webhook +Events: IssueCommentCreate, IssueUpdate +Filter: team = WeOps, label contains 'factory:escalation' +``` + +The webhook is filtered at the Linear level to only fire on +`factory:escalation` issues. This prevents the bridge from processing +every comment in the workspace. + +### 1.2 Webhook verification + +Linear signs webhooks with an HMAC-SHA256 signature in the +`Linear-Signature` header. The bridge verifies this on every request: + +```typescript +function verifyLinearSignature( + payload: string, + signature: string, + secret: string +): boolean { + const expected = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex') + return crypto.timingSafeEqual( + Buffer.from(expected), + Buffer.from(signature) + ) +} +``` + +Requests with invalid or missing signatures are rejected with HTTP 401 +and logged to ArangoDB `bridge_security_events`. They do not fire +escalations. + +--- + +## 2. Webhook Event Processing + +### 2.1 IssueCommentCreate + +Fires when any comment is posted on a `factory:escalation` issue. + +**Processing pipeline:** + +``` +1. Verify Linear signature +2. Extract comment body, issue metadata, commenter identity +3. Check commenter is in AuthorityRegistry + → If not: ignore (not a governance actor) +4. Detect comment type: + a. DISPOSITION comment → DispositionFlow (§3) + b. APPROVED comment → ApprovalFlow (§4) + c. Other → ignore +``` + +### 2.2 IssueUpdate + +Fires when an issue's labels change. + +The bridge only cares about one label addition: +`factory:disposition-recorded` added by someone NOT in the bridge's own +service account. + +This is a secondary trigger: if a human adds the label directly without +posting a disposition comment (e.g., for a pre-existing verbal +disposition), the bridge treats it as a signal to check whether a +disposition comment already exists and process it if found. + +--- + +## 3. Disposition Flow + +### 3.1 Comment format + +The disposition comment must follow a structured format. The bridge +parses this format deterministically — no LLM involved: + +``` +DISPOSITION: {verb} +{field}: {value} +{field}: {value} +... +rationale: {free text — can span multiple lines} +candidatesConsidered: [{comma-separated list}] +rejectedOptions: {id} — {reason} +rejectedOptions: {id} — {reason} +``` + +**Required fields per disposition verb:** + +| Verb | Required fields | +|------|----------------| +| `resume` | `workGraphId`, `workGraphVersion` | +| `commission` | `workGraphId`, `workGraphVersion` | +| `patch` | `changedArtifactId`, `urgency` | +| `pipeline-config` | `proposedConfigId` | +| `override` | `action` (`force-suspend` \| `force-resume` \| `emergency-patch`) | +| `reject` | (no additional fields — closes escalation without action) | + +`rationale`, `candidatesConsidered`, and `rejectedOptions` are required +on all verbs except `reject`. Missing these on a non-reject disposition +produces a parsing error: the bridge replies to the comment with a +structured error message explaining what is missing, and does NOT issue +a token. + +### 3.2 Parsing + +```typescript +type ParsedDisposition = { + verb: DispositionVerb + fields: Record + rationale: string + candidatesConsidered: string[] + rejectedOptions: Array<{ id: string; reason: string }> + rawComment: string + commentId: string + commenterId: string // Linear user ID + commenterName: string + issueId: string // Linear internal ID + escalationId: string // ESC-* from issue custom field + repoId: string // from issue custom field + escalationType: EscalationType +} +``` + +Parsing is strict — unexpected fields are logged but do not block +processing. Unknown verbs produce an error reply. + +### 3.3 Authority check + +```typescript +function checkAuthority( + commenterId: string, + verb: DispositionVerb, + registry: AuthorityRegistry +): { permitted: boolean; requiredApprovals: number } { + const actor = registry.get(commenterId) + if (!actor) return { permitted: false, requiredApprovals: 0 } + + if (verb === 'override') { + // override requires we-layer:override scope AND two-person approval + return { + permitted: actor.scopes.includes('we-layer:override'), + requiredApprovals: 2 + } + } + + const scopeRequired = verbToScope[verb] // e.g. 'resume' → 'we-layer:commission' + return { + permitted: actor.scopes.includes(scopeRequired), + requiredApprovals: 1 + } +} +``` + +If `permitted: false`: bridge replies to comment explaining the +authority requirement and takes no action. + +If `requiredApprovals: 2`: the disposition enters ApprovalFlow (§4) +instead of proceeding immediately. + +### 3.4 A9 enforcement — Elucidation Artifact production + +Before issuing any token, the bridge produces an ELC-* artifact from +the disposition comment content: + +```typescript +type DispositionElucidationArtifact = { + _key: string // ELC-BRIDGE-{escalationId}-{timestamp} + dispositionEventType: 'linear-disposition' + commenterLinearId: string + commenterName: string + linearIssueId: string // WEO-N + linearCommentId: string + verb: DispositionVerb + candidateSet: { + options: string[] // from candidatesConsidered + } + selectedOption: string // the commissioned/resumed/patched artifact + rejectedOptions: Array<{ + id: string + rejectionReason: string + }> + rationale: string + constraintsApplied: string[] // inferred from escalationType + verb + producedAt: string + producedBy: 'ff-linear-bridge' + source: 'ff-linear-bridge' + explicitness: 'stated' + immutable: true +} +``` + +This artifact is written to ArangoDB `elucidation_artifacts` collection +before the JWT is issued. If the ArangoDB write fails: no token is +issued. The bridge replies to the comment: + +``` +⚠️ Disposition received but Elucidation Artifact could not be written +to ArangoDB. The governance record is incomplete. Please retry or +contact the Factory administrator. + +Escalation ID: {escalationId} +Error: {error summary} +``` + +This is A9 enforcement in operation: no ELC-* = no token = no action. + +### 3.5 JWT issuance + +```typescript +type WeOpsDispositionTokenClaims = { + iss: 'weops-gateway' + sub: string // commenterLinearId + aud: 'factory-i-layer' + exp: number // now + 300s (5 minute window) + iat: number + jti: string // unique per disposition; stored in KV for replay prevention + scope: TokenScope[] + dispositionEventId: string // ELC-* artifact ID + elucidationArtifactId: string // same as dispositionEventId +} +``` + +Token expiry is 5 minutes. The WeOps gateway validates `exp` strictly. +If the gateway call fails and the token expires, the bridge must re-issue +(starting from the ArangoDB ELC-* write — the artifact already exists, +so re-issue just produces a new JWT against the same ELC-*). + +The bridge signs with the WeOps private key stored in CF Worker secrets +(`WEOPS_SIGNING_KEY`). The gateway validates with the corresponding +public key. + +### 3.6 Gateway signal construction + +The bridge constructs the appropriate signal type from the disposition +verb and fields: + +```typescript +function buildGatewaySignal( + parsed: ParsedDisposition, + elcArtifactId: string, + token: string +): GatewaySignal { + const base = { + dispositionToken: token, + dispositionEventId: elcArtifactId, + elucidationArtifactId: elcArtifactId, + authorizedBy: parsed.commenterLinearId, + issuedAt: new Date().toISOString(), + } + + switch (parsed.verb) { + case 'resume': + case 'commission': + return { + signalType: 'CommissioningSignal', + ...base, + repoId: parsed.repoId, + workGraphId: parsed.fields.workGraphId, + workGraphVersion: parsed.fields.workGraphVersion, + commissionedBy: parsed.commenterName, + } + case 'patch': + return { + signalType: 'PatchAuthSignal', + ...base, + changedArtifactId: parsed.fields.changedArtifactId, + changeDescription: parsed.rationale, + urgency: (parsed.fields.urgency ?? 'normal') as 'normal' | 'emergency', + } + case 'pipeline-config': + return { + signalType: 'PipelineConfigAuthSignal', + ...base, + proposedConfigId: parsed.fields.proposedConfigId, + affectedLiveRepoIds: [], // populated from ArangoDB lookup + } + case 'override': + return { + signalType: 'OverrideSignal', + ...base, + targetAgentType: parsed.escalationType === 'CRPFail' + ? 'ArchitectAgentDO' + : 'CommissioningAgent', + targetId: parsed.repoId, + action: parsed.fields.action as OverrideAction, + expiresAt: new Date(Date.now() + 300_000).toISOString(), + } + case 'reject': + return null // no signal; close escalation only + } +} +``` + +### 3.7 Gateway call + Linear reply + +```typescript +// 1. POST signal to WeOps Gateway +const gatewayResponse = await fetch( + `${WEOPS_GATEWAY_URL}/signals`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signal), + } +) + +// 2. On success: reply to Linear comment + add factory:disposition-recorded label +if (gatewayResponse.ok) { + await linearClient.createComment(parsed.issueId, buildSuccessReply(parsed, elcArtifactId)) + await linearClient.addLabel(parsed.issueId, LABEL_DISPOSITION_RECORDED) + await linearClient.updateIssueState(parsed.issueId, DONE_STATE_ID) +} + +// 3. On gateway error: reply with error; do NOT add label +if (!gatewayResponse.ok) { + await linearClient.createComment(parsed.issueId, buildErrorReply(parsed, gatewayResponse)) + // Token is now expired/consumed; operator must retry +} +``` + +**Success reply template:** + +``` +✅ Disposition recorded and signal sent to Factory. + +**Action**: {verb} on `{repoId}` +**Elucidation Artifact**: `{elcArtifactId}` +**Signal type**: {signalType} +**Authorized by**: {commenterName} +**Issued at**: {issuedAt} + +The Factory will resume execution once the signal is processed. +This issue has been closed. +``` + +--- + +## 4. Approval Flow (Override Two-Person Rule) + +### 4.1 Trigger + +When `checkAuthority()` returns `requiredApprovals: 2`, the disposition +enters a pending approval state rather than immediately proceeding. + +### 4.2 Pending approval state + +The bridge stores the pending disposition in CF KV: + +```typescript +// Key: pending-override:{escalationId} +type PendingOverride = { + parsed: ParsedDisposition + initiatorLinearId: string + initiatedAt: string + approvals: string[] // Linear user IDs who have posted APPROVED + expiresAt: string // now + 1 hour; prevents stale approvals +} +``` + +The bridge replies to the initiating comment: + +``` +⏳ Override disposition received from {commenterName}. + +This action requires **two-person approval**. A second WeOps team member +with `we-layer:override` scope must post `APPROVED` on this issue within +1 hour. + +**Pending action**: {verb} — {action} +**Escalation ID**: {escalationId} +**Expires at**: {expiresAt} +``` + +### 4.3 APPROVED comment processing + +When a second team member posts `APPROVED`: + +1. Load pending override from KV +2. Verify approver is different from initiator +3. Verify approver has `we-layer:override` scope in AuthorityRegistry +4. Verify not expired +5. Add approver to `approvals` array +6. If `approvals.length >= 2`: proceed with DispositionFlow §3.4 onward +7. If still < 2: update KV, reply with count + +### 4.4 Expiry + +If the override expires (1 hour without two approvals), the bridge +moves the issue back to `In Progress` state and posts: + +``` +⏰ Override approval expired without reaching two approvals. +Approvals received: {approvals.length} of 2 required. +The escalation remains open. Re-post the DISPOSITION comment to restart. +``` + +--- + +## 5. Rejection Flow + +When verb is `reject`: + +1. Validate commenter authority (same as other verbs — must be team member) +2. Write a `RejectionRecord` to ArangoDB: + +```typescript +type RejectionRecord = { + _key: string // REJECT-{escalationId}-{timestamp} + escalationId: string + repoId: string + rejectedBy: string // Linear user ID + rejectedByName: string + rationale: string + producedAt: string + source: 'ff-linear-bridge' + explicitness: 'stated' +} +``` + +3. Post reply to Linear issue: + +``` +❌ Escalation rejected by {commenterName}. + +**Rationale**: {rationale} + +The Factory remains in its current suspended/failed state. +No resume or commission signal has been sent. +A new escalation will be created if the Factory detects further +governance violations requiring human action. + +Rejection record: `{rejectionRecordId}` +``` + +4. Add label `factory:disposition-recorded`, close issue + +Note: A rejection is still a Disposition Event. The Elucidation Artifact +production step (§3.4) applies to rejections — if `candidatesConsidered` +and `rejectedOptions` are present in the comment, an ELC-* is written. +If absent on a rejection, this is permitted (A9 scope restriction: if +the rejection is a constrained disposition with no real candidate set — +e.g., the only option was to reject — elucidation is vacuous). + +--- + +## 6. AuthorityRegistry + +### 6.1 Structure + +```typescript +// CF KV namespace: BRIDGE_KV +// Key: authority-registry +type AuthorityRegistry = Map + +type AuthorityRecord = { + linearUserId: string + linearUserName: string + scopes: TokenScope[] + addedAt: string + addedBy: string +} +``` + +### 6.2 Bootstrap + +The registry is seeded manually via a bootstrap script that reads from +a config file committed to the repo: + +```yaml +# config/linear-authority.yaml +authority: + - linearUserId: "user_abc123" + linearUserName: "Wes" + scopes: + - we-layer:commission + - we-layer:resume + - we-layer:patch + - we-layer:pipeline-config + - we-layer:override +``` + +The bootstrap script writes this to CF KV. Updates to the registry +require re-running the bootstrap script — there is no live editing path, +which is intentional (authority changes are themselves governance events +that should be deliberate). + +### 6.3 Registry miss + +If a commenter is not in the registry: their comment is silently ignored +for governance purposes. The bridge does not reply — replying to every +non-governance comment would be noise. The only exception: if the +comment contains a `DISPOSITION:` prefix but the commenter is not in +the registry, the bridge replies once: + +``` +⚠️ This comment appears to be a governance disposition, but your +Linear user ID is not registered as a governance actor for this Factory. +Contact the Factory administrator to be added to the authority registry. +``` + +--- + +## 7. Error Taxonomy and Recovery + +| Error | Bridge behavior | Linear action | +|-------|----------------|---------------| +| Invalid Linear signature | Reject 401; log security event | None | +| Comment parse failure (malformed DISPOSITION) | Log parse error; post error reply | Comment with format guidance | +| Missing required fields | Log; post error reply | Comment with missing field list | +| Commenter not in registry | Log; conditional reply (§6.3) | None or one reply | +| ArangoDB write failure (ELC-*) | No token issued; post error reply | Comment with retry instruction | +| Gateway 4xx | No retry; post error reply | Comment with error detail | +| Gateway 5xx | Retry 3x with exponential backoff; post error if all fail | Comment if all retries fail | +| Token expiry before gateway success | Re-issue against existing ELC-*; retry gateway | None additional | +| Override expired without 2 approvals | Clear KV; move issue to In Progress | Comment (§4.4) | + +All errors are written to ArangoDB `bridge_error_log` with: +- `escalationId`, `commentId`, `errorType`, `errorDetail`, `timestamp` + +--- + +## 8. Security Constraints + +**No disposition without Linear signature verification.** Every request +is verified before any processing. + +**No token without ELC-*.** The A9 invariant is structural: the code +path to JWT signing runs only after the ArangoDB write succeeds. + +**No override without two approvals.** The `we-layer:override` scope is +never issued from a single comment, regardless of the commenter's +authority level. + +**JTI replay prevention.** Every issued JWT's `jti` is stored in +`BRIDGE_KV` under `jti:{jti}` with a TTL matching token expiry. The +bridge checks this before issuing; the gateway checks it independently. +Two layers of replay prevention. + +**Token expiry: 5 minutes.** Short-lived tokens minimize the window for +replay. Gateway calls are made immediately after issuance. + +**Service account isolation.** The Linear API key used by this bridge +is a service account with read-only access to issues/comments (to +verify comment content) and write access only to comments and labels. +It cannot create or delete issues. The `LinearSyncService` uses a +separate API key with broader write access. + +--- + +## 9. Package Structure + +``` +packages/linear-bridge/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts — CF Worker default export + ├── types.ts — webhook payloads, ParsedDisposition, etc. + ├── webhook-verifier.ts — Linear HMAC-SHA256 signature verification + ├── disposition-parser.ts — structured comment parsing (no LLM) + ├── authority-registry.ts — CF KV-backed authority registry + ├── elucidation-writer.ts — A9: ELC-* artifact production + ArangoDB write + ├── token-issuer.ts — JWT signing with WeOps private key + ├── signal-builder.ts — typed gateway signal construction + ├── gateway-client.ts — WeOps gateway HTTP client with retry + ├── approval-flow.ts — two-person override approval state machine + ├── rejection-flow.ts — rejection record + issue close + ├── linear-client.ts — Linear GraphQL API (comments, labels, state) + └── error-log.ts — ArangoDB bridge_error_log writer +``` + +--- + +## 10. Environment Bindings + +```typescript +type Env = { + LINEAR_WEBHOOK_SECRET: string // for HMAC verification + LINEAR_API_KEY: string // service account (read + comment/label write) + WEOPS_SIGNING_KEY: string // WeOps private key for JWT signing + WEOPS_GATEWAY_URL: string // WeOps gateway base URL + ARANGO_URL: string + ARANGO_DB: string + ARANGO_TOKEN: string + BRIDGE_KV: KVNamespace // authority registry, pending overrides, JTI store +} +``` + +--- + +## 11. Wrangler Configuration + +```toml +name = "ff-linear-bridge" +main = "src/index.ts" +compatibility_date = "2026-01-01" + +[[kv_namespaces]] +binding = "BRIDGE_KV" +id = "{kv-namespace-id}" + +[vars] +WEOPS_GATEWAY_URL = "https://ff-gateway.koales.workers.dev" + +# Secrets (set via wrangler secret put): +# LINEAR_WEBHOOK_SECRET +# LINEAR_API_KEY +# WEOPS_SIGNING_KEY +# ARANGO_URL, ARANGO_DB, ARANGO_TOKEN +``` + +--- + +## 12. Open Items + +| Item | Owner | Blocking | +|------|-------|---------| +| WeOps public key distribution to gateway — bridge signs, gateway verifies; both need the same key pair | Engineering | Yes — needed before end-to-end test | +| Linear service account provisioning — two separate accounts (LinearSyncService read-write, bridge read + comment/label) | Engineering | No — single account acceptable for v1 | +| `config/linear-authority.yaml` initial content — which Linear user IDs get which scopes | Architect (Wes) | No — bootstrap can run after bridge is deployed | +| `AffectedLiveRepoIds` population for `PipelineConfigAuthSignal` — bridge needs an ArangoDB lookup for currently ACTIVE repos | Engineering | No — can send empty array for v1 (Architect Agent ignores it when empty) | +| Disposition comment format documentation — team needs a reference card for how to write valid disposition comments | Engineering | No — can write after bridge is stable | diff --git a/docs/linear-integration/SPEC-LINEAR-SYNC-SERVICE-001.md b/docs/linear-integration/SPEC-LINEAR-SYNC-SERVICE-001.md new file mode 100644 index 00000000..7050af28 --- /dev/null +++ b/docs/linear-integration/SPEC-LINEAR-SYNC-SERVICE-001.md @@ -0,0 +1,775 @@ +# LinearSyncService Specification +**ID**: SPEC-LINEAR-SYNC-SERVICE-001 +**Status**: Draft — pending Architect sign-off +**Date**: 2026-06-05 +**Layer**: I-layer runtime — Linear integration +**Package**: `@factory/linear-sync` +**Depends on**: `@factory/schemas`, `@factory/knowing-state-sdk`, + Linear GraphQL API (MCP or direct) + +--- + +## 0. Conceptual Preamble + +### 0.1 What LinearSyncService IS + +LinearSyncService is the single responsible party for keeping Linear +consistent with the Factory's artifact graph. It is not a two-way sync +engine — it is a one-way projection layer. The Factory's ArangoDB graph is +the source of truth. Linear is a human-readable view over that graph, +maintained by this service. + +The service has four projection responsibilities: + +| Responsibility | Source artifact | Linear artifact | +|----------------|----------------|-----------------| +| P1: Atom projection | `AtomDirective` (on CommissionEvent) | Issue under WorkGraph milestone | +| P2: Trace state sync | `TraceEvent` (on Bead flush) | Issue state transition | +| P3: Divergence projection | `DivergenceEvent` (on Bead flush) | Issue under parent atom issue | +| P4: Health document | `HealthSummary` (on Architect Agent alarm) | Living Linear document | + +Direction 5 (commit tracing) and Direction 4 (cycle cadence) are consumers +of this service, not part of it. Direction 2 (ff-linear-bridge) depends on +issues created by P1 and P3. This service creates them; the bridge reads them. + +### 0.2 What LinearSyncService is NOT + +It is not a webhook receiver. It does not receive events from Linear and +push them to the Factory. That is `ff-linear-bridge`'s responsibility +(SPEC-FF-LINEAR-BRIDGE-001, to be written). + +It does not own the Linear project, team, or milestone structure. Those +are created manually or by a one-time bootstrap script. This service +operates within an existing project structure. + +It does not make governance decisions. It projects what the Factory has +already decided. If an atom fails, this service updates the Linear issue +to reflect that failure — it does not decide how to respond. + +### 0.3 Idempotency Principle + +Every operation this service performs must be idempotent. The Factory's +event log may replay events (e.g., on DO wake after hibernation, during +Bead flush retry). Linear must not end up with duplicate issues or +duplicate state transitions. + +Idempotency is enforced via a binding table in ArangoDB: + +```typescript +// ArangoDB collection: linear_bindings +type LinearBinding = { + _key: string // factory artifact ID (directiveId, divergenceId, etc.) + linearIssueId: string // WEO-N identifier + linearIssueInternalId: string // Linear UUID (for API calls) + bindingType: 'atom' | 'divergence' | 'escalation' | 'health-document' + workGraphVersion: string + createdAt: string + lastSyncedAt: string +} +``` + +Before any Linear write, the service checks this table. If a binding +exists, it updates the existing issue. If not, it creates a new issue +and writes the binding. + +--- + +## 1. Architecture + +LinearSyncService is a **stateless Cloudflare Worker** invoked by three +callers: + +``` +MediationAgentDO alarm (Bead flush) + → POST /sync/atoms P1 + P2 (atom projection + trace state) + → POST /sync/divergences P3 (divergence projection) + +ArchitectAgentDO alarm (15-min health scan) + → POST /sync/health P4 (health document update) + +CommissioningAgent (on escalation) + → POST /sync/escalation Creates escalation issue (D2 dependency) +``` + +All endpoints are internal — not exposed to the public internet. + +The Worker calls the Linear GraphQL API directly. It does not use the +Linear MCP in the runtime path (MCP is session-based; this Worker runs +on alarms without a user session). It uses a Linear API key stored in +CF Worker secrets. + +--- + +## 2. P1: Atom Projection + +### 2.1 Trigger + +Called from `MediationAgentDO.alarm()` after Bead flush, once per +`CommissionEvent` in the flush batch. + +### 2.2 Input + +```typescript +type AtomSyncRequest = { + repoId: string + workGraphId: string + workGraphVersion: string + policyBeadId: string + projectId: string // Linear project ID + milestoneId: string // Linear milestone ID for this WorkGraph version + atoms: AtomDirective[] + elucidationArtifactId: string // ELC-* ref — for A9 content in issue description +} +``` + +`milestoneId` is resolved by the service from a `WorkGraphMilestoneBinding` +table (see §5.1). If no milestone exists for this WorkGraph version, the +service creates one before creating atom issues. + +### 2.3 Issue creation + +For each `AtomDirective` in `atoms`: + +1. Check `linear_bindings` for `_key: directive.directiveId` +2. If binding exists and `workGraphVersion` matches: skip (already synced) +3. If binding exists and `workGraphVersion` differs: this is a WorkGraph + version change — label the old issue `factory:superseded`, move to + `Cancelled` state, create a new issue for the new version +4. If no binding: create issue + +**Issue fields:** + +```typescript +{ + title: truncate(directive.instruction, 80), + teamId: LINEAR_TEAM_ID, + projectId: request.projectId, + milestoneId: request.milestoneId, + stateId: BACKLOG_STATE_ID, + labelIds: [LABEL_FACTORY_ATOM, LABEL_FACTORY_ACTIVE], + description: buildAtomDescription(directive, elucidationArtifact), +} +``` + +**Description template:** + +```markdown +## Instruction +{directive.instruction} + +## Execution parameters +- Permitted tools: {directive.permittedTools.join(', ')} +- Timeout: {directive.timeoutMs}ms +- Retry: {directive.retryPolicy.maxAttempts} attempts, + isolated={directive.retryPolicy.isolatedRetry} +- Working dir: {directive.workingDir} + +## Success condition +{renderSuccessCondition(directive.successCondition)} + +## Dependencies +{directive.dependsOn.length > 0 + ? directive.dependsOn.map(id => `- ${id}`).join('\n') + : 'None'} + +## Identity +- Directive ID: `{directive.directiveId}` +- Atom ref: `{directive.atomRef}` +- WorkGraph: `{directive.workGraphVersion}` +- Policy Bead: `{request.policyBeadId}` + +## Elucidation (A9) +At the time this WorkGraph was commissioned, the following alternatives +were considered: + +{elucidationArtifact.candidateSet.workGraphVersions.map(v => + v === elucidationArtifact.selectedOption + ? `- **${v}** ← selected` + : `- ${v} — rejected: ${elucidationArtifact.rejectedOptions + .find(r => r.workGraphVersion === v)?.rejectionReason ?? 'not recorded'}` +).join('\n')} +``` + +5. Write `LinearBinding` to ArangoDB +6. Append `IssueBindingEvent` to Mediation Agent DO event log: + +```typescript +type IssueBindingPayload = { + directiveId: string + linearIssueId: string // WEO-N + linearIssueInternalId: string + workGraphVersion: string +} +``` + +### 2.4 Dependency linking + +After all atom issues are created, for each atom with non-empty `dependsOn`: + +```graphql +mutation { + issueRelationCreate(input: { + issueId: $issueInternalId, + relatedIssueId: $dependencyInternalId, + type: blocks + }) +} +``` + +The `dependsOn` directive IDs are resolved to Linear internal IDs via +the `linear_bindings` table. If a dependency's binding does not yet +exist (possible if atoms are synced in an unexpected order), this step +is deferred and retried on the next flush. + +--- + +## 3. P2: Trace State Sync + +### 3.1 Trigger + +Same alarm as P1 — called after each Bead flush. For each `CommitBead` +written to ArangoDB in the flush, resolve the corresponding Linear issue +via `linear_bindings` and update its state. + +### 3.2 State machine mapping + +| TraceEvent outcome + context | Linear state | Labels added | +|------------------------------|-------------|-------------| +| Atom dispatched (CommitBead written, no OutcomeBead yet) | `In Progress` | — | +| `outcome: success` | `Done` | `factory:success` | +| `outcome: failure`, `attemptNumber < maxAttempts` | `In Review` | `factory:retrying` | +| `outcome: failure`, `attemptNumber >= maxAttempts` | `Cancelled` | `factory:divergence`, `factory:failure` | +| `outcome: timeout`, retries exhausted | `Cancelled` | `factory:divergence`, `factory:timeout` | +| `outcome: cancelled` (dependency failed) | `Cancelled` | `factory:dependency-failed` | + +### 3.3 Retry comment + +When an atom transitions to `In Review` (retry in progress), append a +comment to the issue: + +``` +Retry {attemptNumber} of {maxAttempts} in progress. +Previous failure: {rawOutput.slice(0, 500)} +Full output: {sandboxOutputRef ?? 'not available'} +``` + +### 3.4 Success comment + +When an atom transitions to `Done`, append a comment: + +``` +Atom completed successfully on attempt {attemptNumber}. +Duration: {durationMs}ms +Commit: {commitSha ?? 'no git operation performed'} +``` + +The `commitSha` is populated by the `post_execution.ts` hook (Direction 5). +If no git operation was performed by this atom, it is omitted. + +--- + +## 4. P3: Divergence Projection + +### 4.1 Trigger + +Called from Bead flush for each `BuildOutcomeBead` with +`outcome: 'divergence'` written in that flush. + +### 4.2 Input + +```typescript +type DivergenceSyncRequest = { + repoId: string + workGraphVersion: string + divergenceId: string // DIV-* ref + atomRef: string + detectorId: string // INV-* ref + severity: 'blocking' | 'advisory' | 'informational' + evidence: { + rawOutputFragment: string // first 500 chars + sandboxOutputRef?: string + traceSeqRef: string + } + elucidationArtifactId: string // ELC-* from original commission +} +``` + +### 4.3 Issue creation + +1. Check `linear_bindings` for `_key: divergenceId` +2. If binding exists: skip (already projected) +3. Resolve parent atom issue via `linear_bindings` on `atomRef` +4. Create divergence issue: + +```typescript +{ + title: `[DIVERGENCE ${severity.toUpperCase()}] ${detectorId} on ${atomRef}`, + teamId: LINEAR_TEAM_ID, + projectId: parentAtomIssue.projectId, + parentId: parentAtomIssueInternalId, + stateId: resolveDivergenceState(severity), + labelIds: buildDivergenceLabels(severity), + description: buildDivergenceDescription(request, elucidationArtifact), +} +``` + +**State by severity:** + +| Severity | Initial state | +|----------|--------------| +| `blocking` | `In Progress` (requires immediate governance attention) | +| `advisory` | `Backlog` (queued for cycle-boundary review) | +| `informational` | `Backlog` + `factory:informational` label | + +**Labels by severity:** + +| Severity | Labels | +|----------|--------| +| `blocking` | `factory:divergence`, `factory:blocking` | +| `advisory` | `factory:divergence`, `factory:advisory` | +| `informational` | `factory:divergence`, `factory:informational` | + +**Description template:** + +```markdown +## What fired +Detector `{detectorId}` matched on output from atom `{atomRef}`. + +Severity: **{severity}** + +## Evidence +{rawOutputFragment} + +{sandboxOutputRef ? `Full output: ${sandboxOutputRef}` : ''} + +## Elucidation (A9) +At the time this atom's WorkGraph was commissioned, the following +alternatives were available. These are preserved here to support +Hypothesis formation. + +{renderElucidationContent(elucidationArtifact)} + +## Identity +- Divergence ID: `{divergenceId}` +- Detector: `{detectorId}` +- WorkGraph: `{workGraphVersion}` +- Trace ref: `{traceSeqRef}` +``` + +5. Write `LinearBinding` for `divergenceId` + +### 4.4 Divergence lifecycle updates + +When the Commissioning Agent forms a Hypothesis (HYP-*), it calls +`POST /sync/hypothesis`: + +```typescript +type HypothesisSyncRequest = { + divergenceId: string + hypothesisId: string // HYP-* ref + hypothesisContent: string // rendered hypothesis text + amendmentId?: string // AMD-* if already proposed +} +``` + +The service appends a comment to the divergence issue: + +``` +**Hypothesis formed**: {hypothesisId} + +{hypothesisContent} + +{amendmentId ? `**Amendment proposed**: ${amendmentId}` : ''} +``` + +When the Commissioning Agent closes a Divergence (`DivergenceClosedEvent`), +it calls `POST /sync/divergence-closed`: + +```typescript +type DivergenceClosedRequest = { + divergenceId: string + closedBy: string // AMD-* or 'commissioning-agent-override' + resolution: string // human-readable resolution summary +} +``` + +The service transitions the divergence issue to `Done` and appends: + +``` +**Divergence resolved** by {closedBy}. +{resolution} +``` + +--- + +## 5. P4: Health Document + +### 5.1 Trigger + +Called from `ArchitectAgentDO.alarm()` after each anomaly scan. Also +called on any factory lifecycle state change (EMERGENCY_SUSPEND, +MAINTENANCE). + +### 5.2 Document management + +The service maintains two Linear documents per Factory deployment: + +- **`Factory Health — Live`**: current-state snapshot, full-replace on + each call +- **`Factory Health — History`**: append-only daily snapshot at midnight + +Both documents live in the `Function Factory Agent Infrastructure` +project. Document IDs are stored in a CF KV namespace +(`FACTORY_LINEAR_KV`) under keys `health-doc-live-id` and +`health-doc-history-id`. If they don't exist on first call, the service +creates them. + +### 5.3 Input + +```typescript +type HealthSyncRequest = { + factoryLifecycleState: string + activeRepos: RepoHealthSummary[] + openDivergences: { blocking: number; advisory: number; informational: number } + openEscalations: EscalationSummary[] + activePatches: PatchSummary[] + pendingCrpCount: number + pipelineConfig: PipelineConfig + producedAt: string +} + +type RepoHealthSummary = { + repoId: string + healthStatus: string + lifecycleState: string + lastCommissionAt: string + escalationLinearIssueId?: string // WEO-N if suspended with open escalation +} + +type EscalationSummary = { + escalationId: string + repoId: string + escalationType: string + linearIssueId: string // WEO-N + openSince: string +} + +type PatchSummary = { + patchId: string + trigger: string + appliedToRepoIds: string[] + pendingRepoIds: string[] +} +``` + +### 5.4 Live document template + +```markdown +# Factory Health +_Last updated: {producedAt}_ + +{factoryLifecycleState !== 'ACTIVE' + ? `> ⚠️ FACTORY ${factoryLifecycleState} — see escalation issues below\n` + : ''} + +## Status +**Factory lifecycle**: {factoryLifecycleState} +**Active repos**: {activeRepos.length} +({activeRepos.filter(r => r.healthStatus === 'healthy').length} healthy, + {activeRepos.filter(r => r.healthStatus === 'degraded').length} degraded, + {activeRepos.filter(r => r.lifecycleState === 'SUSPENDED').length} suspended) + +## Open Governance Items +| Type | Count | Action | +|------|-------|--------| +| Blocking Divergences | {openDivergences.blocking} | {openDivergences.blocking > 0 ? 'Requires Hypothesis formation' : '—'} | +| Open Escalations | {openEscalations.length} | {openEscalations.length > 0 ? 'Requires We-layer Disposition' : '—'} | +| Open CRPs | {pendingCrpCount} | {pendingCrpCount > 0 ? 'In Architect Agent D2 resolution' : '—'} | +| Active Patches | {activePatches.length} | {activePatches.length > 0 ? 'D1 propagation in progress' : '—'} | +| Advisory Divergences | {openDivergences.advisory} | Queued for cycle boundary review | + +{openEscalations.length > 0 ? ` +## Open Escalations +${openEscalations.map(e => + `- **${e.escalationType}** on \`${e.repoId}\` — ${e.linearIssueId} — open since ${e.openSince}` +).join('\n')} +` : ''} + +{activeRepos.some(r => r.healthStatus !== 'healthy') ? ` +## Degraded / Suspended Repos +${activeRepos + .filter(r => r.healthStatus !== 'healthy') + .map(r => { + const line = `- \`${r.repoId}\` — ${r.lifecycleState}` + return r.escalationLinearIssueId + ? `${line} → escalation: ${r.escalationLinearIssueId}` + : line + }).join('\n')} +` : ''} + +{activePatches.length > 0 ? ` +## Active Patches +${activePatches.map(p => + `- ${p.patchId} — ${p.trigger}\n` + + ` Applied: ${p.appliedToRepoIds.length} repos | Pending: ${p.pendingRepoIds.join(', ')}` +).join('\n')} +` : ''} + +## Pipeline Config +- Config ID: \`{pipelineConfig.configId}\` (effective {pipelineConfig.effectiveFrom}) +- Pass routing: {renderPassRouting(pipelineConfig.passRouting)} +- Coherence threshold: {pipelineConfig.gateThresholds.coherenceMinCoverage * 100}% +- Fidelity max blocking: {pipelineConfig.gateThresholds.fidelityMaxOpenBlockingDivergences} +- Staleness threshold: {pipelineConfig.gateThresholds.assuranceMaxDetectorStalenessHours}h +- Vertical slicing: isolated={pipelineConfig.verticalSlicePolicy.atomRetryIsolation}, + parallel threshold={pipelineConfig.verticalSlicePolicy.parallelSliceThreshold}, + DAG dispatch={pipelineConfig.verticalSlicePolicy.dagDispatchEnabled} +``` + +--- + +## 6. Escalation Issue Creation + +This endpoint is called by the Commissioning Agent when it calls +`escalateToWeLayer()`. It creates the Linear issue that Direction 2 +(`ff-linear-bridge`) will watch for human disposition. + +### 6.1 Input + +```typescript +type EscalationSyncRequest = { + escalationId: string // ESC-* ref in ArangoDB + repoId: string + escalationType: EscalationType + requestedAction: string + evidence: { + divergenceIds?: string[] + hypothesisId?: string + amendmentId?: string + coherenceVerdictDetail?: string + crpId?: string + patchId?: string + proposedConfigId?: string + } + linearDivergenceIssueIds: string[] // WEO-N refs for linked divergences +} +``` + +### 6.2 Issue creation + +```typescript +{ + title: `[ESCALATION] ${repoId} — ${escalationType}`, + teamId: LINEAR_TEAM_ID, + projectId: FACTORY_PROJECT_ID, + stateId: IN_PROGRESS_STATE_ID, + priority: escalationType === 'AutoSuspend' ? 1 : 2, // Urgent for suspensions + labelIds: buildEscalationLabels(escalationType), + description: buildEscalationDescription(request), +} +``` + +**Labels by escalation type:** + +| EscalationType | Labels | +|----------------|--------| +| `AutoSuspend` | `factory:escalation`, `factory:requires-resume` | +| `AmendmentCoherenceFail` | `factory:escalation`, `factory:requires-new-workgraph` | +| `CommissionFail` | `factory:escalation`, `factory:requires-new-workgraph` | +| `CRPFail` | `factory:escalation`, `factory:requires-crp-resolution` | +| `PatchPropagationFail` | `factory:escalation`, `factory:requires-patch-auth` | +| `PipelineAnomalyDetected` | `factory:escalation`, `factory:requires-pipeline-config` | + +**Blocking links**: if `linearDivergenceIssueIds` is non-empty, each +divergence issue is linked as blocking the escalation issue. The +escalation is unresolvable until its constituent Divergences are closed. + +**Description template:** + +```markdown +## Escalation +**Type**: {escalationType} +**Repo**: `{repoId}` +**Requested action**: {requestedAction} + +## Evidence +{renderEvidenceSection(evidence)} + +## Disposition instructions +To resolve this escalation, post a comment with the following structure: + +\`\`\` +DISPOSITION: {inferDispositionVerb(escalationType)} +{inferDispositionFields(escalationType)} +rationale: +candidatesConsidered: [] +rejectedOptions: +\`\`\` + +Then add the label \`factory:disposition-recorded\` to close this issue. +The \`ff-linear-bridge\` webhook handler will pick up the disposition +and fire the appropriate signal to the WeOps gateway. + +## Identity +- Escalation ID: `{escalationId}` +- Factory artifact refs: {renderArtifactRefs(evidence)} +``` + +--- + +## 7. Linear Label Bootstrap + +Before the service can run, the following labels must exist in the +Linear workspace. The service checks for them on startup and creates +any that are missing. + +```typescript +const REQUIRED_LABELS = [ + { name: 'factory:atom', color: '#0ea5e9' }, // sky blue + { name: 'factory:active', color: '#22c55e' }, // green + { name: 'factory:superseded', color: '#94a3b8' }, // slate + { name: 'factory:success', color: '#22c55e' }, // green + { name: 'factory:retrying', color: '#f59e0b' }, // amber + { name: 'factory:divergence', color: '#ef4444' }, // red + { name: 'factory:blocking', color: '#dc2626' }, // dark red + { name: 'factory:advisory', color: '#f97316' }, // orange + { name: 'factory:informational', color: '#94a3b8' }, // slate + { name: 'factory:failure', color: '#ef4444' }, // red + { name: 'factory:timeout', color: '#a855f7' }, // purple + { name: 'factory:dependency-failed', color: '#6b7280' }, // gray + { name: 'factory:escalation', color: '#dc2626' }, // dark red + { name: 'factory:requires-resume', color: '#f59e0b' }, // amber + { name: 'factory:requires-new-workgraph', color: '#f59e0b' }, + { name: 'factory:requires-patch-auth', color: '#f59e0b' }, + { name: 'factory:requires-pipeline-config', color: '#f59e0b' }, + { name: 'factory:requires-crp-resolution', color: '#f59e0b' }, + { name: 'factory:disposition-recorded', color: '#22c55e' }, // green + { name: 'factory:cycle-boundary', color: '#8b5cf6' }, // violet + { name: 'factory:carried-over', color: '#6b7280' }, // gray +] +``` + +Labels are stored in a `LabelBinding` KV namespace after creation to +avoid redundant API calls on every startup. + +--- + +## 8. Milestone Management + +### 8.1 WorkGraph version → Linear milestone + +For every distinct `workGraphVersion` commissioned to a repo, the +service ensures a corresponding Linear milestone exists under the +project. + +```typescript +// KV: FACTORY_LINEAR_KV +// Key: milestone:{repoId}:{workGraphVersion} +// Value: { milestoneId: string, milestoneInternalId: string } +``` + +On P1 atom projection, if no milestone binding exists: + +```graphql +mutation { + projectMilestoneCreate(input: { + projectId: $projectId, + name: "WG-{workGraphVersion}", + description: "WorkGraph {workGraphId} version {workGraphVersion}" + }) +} +``` + +### 8.2 WorkGraph version change + +When a new WorkGraph version supersedes an old one, the old milestone +is not deleted — it remains as a historical record. All atom issues +under the old milestone are labeled `factory:superseded` and moved to +`Cancelled`. New atom issues are created under the new milestone. + +--- + +## 9. Rate Limiting and Batching + +The Linear API has rate limits. The service batches writes to stay +within limits. + +**Per-flush batch limits:** +- Max 50 issue creates per flush call +- Max 100 issue state updates per flush call +- Max 20 comment appends per flush call +- If batch exceeds limits: split into sub-batches with 500ms delay + +**Backoff on 429:** exponential backoff starting at 1s, max 16s, max 5 +retries. On persistent rate limit: log to ArangoDB `linear_sync_errors` +collection; do not block the Bead flush. + +**GraphQL batching:** use Linear's multi-mutation GraphQL support to +batch multiple issue creates into a single request where possible. + +--- + +## 10. Error Handling + +All Linear API failures are non-blocking for the Factory's governance +loop. A Linear write failure must never cause a Mediation Agent flush +to fail or a Commissioning Agent to halt. + +On failure: +1. Log error to ArangoDB `linear_sync_errors` collection with artifact + ID, error type, timestamp +2. Mark the `LinearBinding` (if partially created) with + `syncStatus: 'error'` +3. Retry on next flush cycle (the binding table prevents duplicates) +4. After 5 consecutive failures for the same artifact: surface in + Health Document as `linear_sync_degraded: true` + +--- + +## 11. Environment Bindings + +```typescript +type Env = { + LINEAR_API_KEY: string // Linear personal API key (service account) + LINEAR_TEAM_ID: string // WeOps team ID + LINEAR_PROJECT_ID: string // Function Factory Agent Infrastructure project ID + ARANGO_URL: string + ARANGO_DB: string + ARANGO_TOKEN: string + FACTORY_LINEAR_KV: KVNamespace // milestone bindings, document IDs, label bindings +} +``` + +--- + +## 12. Package Structure + +``` +packages/linear-sync/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts — CF Worker default export + router + ├── types.ts — request/response types + LinearBinding + ├── label-bootstrap.ts — ensure required labels exist on startup + ├── milestone-manager.ts — WorkGraph version → milestone binding + ├── p1-atom-projection.ts — atom issue create/update + ├── p2-trace-state-sync.ts — issue state transitions on trace events + ├── p3-divergence-projection.ts — divergence issue create/lifecycle + ├── p4-health-document.ts — live + history document management + ├── escalation-sync.ts — escalation issue creation (D2 support) + ├── linear-client.ts — GraphQL API wrapper with batching + backoff + └── binding-store.ts — ArangoDB linear_bindings CRUD +``` + +--- + +## 13. Open Items + +| Item | Owner | Blocking | +|------|-------|---------| +| Linear GraphQL API auth — service account vs. OAuth token for worker runtime | Engineering | No — personal API key acceptable for v1 | +| `ff-linear-bridge` spec — the D2 webhook handler that reads escalation issues and fires gateway signals | Architect | No — LinearSyncService creates the issues; bridge spec is separate | +| `CycleAwarenessService` spec — D4 cycle cadence, advisory surfacing at cycle boundary | Architect | No — independent of this service | +| Direction 5 commit tracing — `post_execution.ts` hook writes `commitSha` back to atom issue via this service | Engineering | No — hook calls `POST /sync/commit-sha` (endpoint not yet specified here) | +| Linear state IDs — Backlog, In Progress, In Review, Done, Cancelled state UUIDs must be read from the API at bootstrap and stored in KV | Engineering | No — discoverable at runtime |