Historical field note
This design/spec note is retained for provenance only. It is not a current operating contract; the premises or implementation path it describes have been superseded or never shipped.
Linear Bridge — Design Spec¶
Status: deferred 2026-05-28 — implementation paused pending end-to-end validation of the existing beads-backed mac pipeline (Hermes → mac-api → mac-k8s-runner → Job → evidence → review). No code has been written against this spec. When e2e is confirmed working, Phase L0 (outbox extension) is the first concrete PR.
Spec was reviewed by both an architect subagent and
codex exec(codex-cli 0.134.0) before being deferred. v2 post-review revisions are below; seelinear-bridge-spec-review.mdfor the codex review notes.See also:
docs/archive/field-notes/k8s-native-rewrite-plan.mdand the legacy import/repository registry section indocs/production-deployment.md.
Revision history¶
v2 (post-review) — Addressed findings from architect + codex CLI reviews. Substantive changes:
- Removed
TrackerBackendProtocol from v1. The "behavior-preserving retrofit ofBeadsBridgeService" claim was wrong; real beads logic lives inControlPlanemethods inservices.py, not in the 88-linebeads_bridge_service.pysubprocess wrapper. Dispatch in v1 uses simple branching ontenants.tracker_kind; protocol extraction is deferred to a follow-up refactor after both bridges are working. - Added Phase L0 for outbox extension. The existing
task_transition_outboxhas nonext_attempt_at, nomax_attempts, nodead_letterstatus, andmark_outbox_failedremoves rows from the pending query (no auto-retry). Spec's previous claim that "the outbox already handles retries with exponential backoff" was false. L0 fixes this; every other phase depends on it. - Rewrote auto-register secret handling (§10).
SecretsServicehas noupsertmethod. The spec now uses an explicit get-or-rotate pattern:find_by_name → if absent create_secret, else compare hash → rotate_secret if changed. - Per-team state_map (§9.1). Linear workflow state UUIDs are
per-team; flat
{name: uuid}collides across teams. Restructured to{team_id: {state_name: state_uuid}}and refreshed every poll (one cheap GraphQL call) rather than lazy-on-unknown-name. - Single-transaction upsert (§9.1). Spec previously claimed
UNIQUE(source, external_id)gave idempotency for free; in reality the existingimport_project_itemis SELECT→create_task→INSERT which is race-prone across replicas. v2 specifies a single transaction with task + project_item upserts together. - Lifecycle comment format (§9.2). Spec previously showed
mac-ledger v1: ...markdown; actual beads comments are pipe-delimited (task=..., event=..., actor=...). v2 matches the existing format. - Auth scope mapping (§11). mac-api's
_required_scopehas no/bridges/case today; v2 explicitly adds the mapping rules. - Plugin file fixes (§13).
provides_toolslives inplugin.yaml, notplugin/manifest.py. v2 lists every file each new tool touches. - Three more plugin tools added (§13). Architect flagged that
comment/state/link_pr alone is insufficient. v2 adds
mac_set_task_priority,mac_add_task_label,mac_assign_task. - Added Observability (§21), Tenant deletion (§22), Auth (§23) sections that were missing.
- Fixed factual errors:
integration_findingsis the correct table name (architect was wrong; codex was right perschema.sql:724). Drift code isbeads.export_drift.ready_mismatch, notjsonl_only_ready. Beads writeback failures are silently swallowed in_run_bd_for_task— flagged as a pre-existing bug, out of scope for this spec but noted.
1. Goal¶
Let mac integrate Linear as an external issue-tracker source on equal footing with the existing beads bridge. A workspace operator chooses which tracker (beads or Linear) backs each tenant; the rest of mac (task ledger, claim semantics, runners, evidence) is unchanged.
Concretely:
- A new issue created in Linear by a human appears in mac's
taskstable within one poll tick, tagged withmetadata.origin.tracker = "linear". - Mac-managed lifecycle transitions (
claimed,running,needs_review,completed,failed) mirror back to Linear as comments on the originating Issue. - Hermes'
mac_create_taskplugin tool routes to Linear (via mac-api) when the calling Hermes instance's tenant is Linear-backed; mac creates the Issue and the local task row in one round trip. - Operators wire it declaratively via env vars (Kubernetes-friendly); the CLI remains available as an escape hatch.
2. Non-goals¶
- Replacing beads. Beads stays in tree and operational; per-tenant config selects which backend to use.
- Replacing the mac task ledger. Linear is the source of truth for
issue identity and human-facing state. Mac's
tasks/task_history/evidence/leasestables remain the source of truth for execution state. - Webhook support in v1. Polling at ~30s tick is sufficient and avoids the public-endpoint complexity. Webhooks may come in a Phase L4.
- Multi-workspace via env vars. v1 auto-register supports one workspace per mac instance; additional workspaces require the imperative CLI. Multi-workspace declarative support is deferred.
- Migration of existing beads-origin mac tasks into Linear. New tasks only. Beads-origin tasks keep their origin metadata forever.
3. Pain this addresses¶
The beads workflow has a three-source-of-truth problem:
- Embedded Dolt DB (
.beads/embeddeddolt/) — canonical operational state .beads/issues.jsonl— git-tracked export- Dolt remote (
bd dolt push/pull) — cross-machine sync
Plus mac's .tickets/<id>.md mirror layer added recently. When any
two drift, mac reports beads.export_drift.jsonl_only_ready as an
integration_findings row and stops importing the drifted issues
until the operator reconciles. Operationally this means manual
bd dolt pull → bd export → commit cycles whenever state changes,
and services.py:_sync_beads_database has had to disable Dolt sync
entirely because the failure modes are too sharp for automated use.
Linear sidesteps all of this. Its hosted DB is the single source of
truth; mac's mirror is one-way (read-only from mac's perspective for
identity/description fields). Lifecycle comments are written by mac
or by agents directly, with retries handled by
task_transition_outbox. No git involvement for issue state.
4. Design overview¶
┌────────────────┐
│ Linear (API) │
└───────┬────────┘
│ GraphQL — poll (and agent-driven writes)
▼
┌──────────────────────┐
│ LinearBridgeService │
│ (new module) │
└───────┬──────────────┘
│
▼
┌────────────────┐ ┌───────────────────────┐
│ mac.tasks │◀────────│ Beads bridge methods │
└────────────────┘ │ on ControlPlane │
▲ │ │ (services.py — many) │
│ │ └───────────────────────┘
│ │
│ └── task_transition_outbox (v2: extended in L0
│ with next_attempt_at, last_error,
│ max_attempts, dead_letter status)
│ │
│ ▼
│ dispatch: if task.metadata.origin.tracker == "linear":
│ linear_bridge.emit_lifecycle_comment(...)
│ else: _append_beads_ledger_comment(...)
│
└── mac-k8s-runner / leases / evidence / reviews (unchanged)
v2 design call: there is no TrackerBackend Protocol. The two
bridges are independent services (one already-exists-distributed across
ControlPlane methods, one new module). Consumer code (outbox emitter,
mac-api endpoints, plugin tool handlers) dispatches via a simple
helper:
def get_tracker_kind_for_task(task: Task) -> str:
return (task.metadata.get("origin") or {}).get("tracker", "beads")
def emit_lifecycle_comment(cp, task, event_type, payload):
kind = get_tracker_kind_for_task(task)
if kind == "linear":
cp.linear_bridge.emit_lifecycle_comment(task, event_type, payload)
else:
cp._append_beads_ledger_comment(task, event_type, payload)
That's ugly but honest. A proper TrackerBackend Protocol requires
first extracting the beads logic out of ControlPlane into a real
service — a behavior-preserving refactor of ~1500 lines of
services.py. That refactor is deferred to a follow-up; v1 ships the
Linear bridge alongside the existing beads layout, with branching
dispatch in maybe 4-6 places total.
Rationale: the v1 priority is "does Linear actually work end-to-end." Premature abstraction adds risk (the beads retrofit might break existing behavior) without delivering user-facing value. Extract the abstraction once both bridges have been operating in production.
5. Source of truth boundary¶
| Concern | Source of truth |
|---|---|
| Issue identity, title, description, priority, labels, assignees | Linear |
| Linear-state name (Todo / In Progress / Done / ...) | Linear |
| mac task id, mac state, claim/lease, evidence, attempts | mac |
tasks.metadata.origin.tracker |
mac (set at import time) |
linear_workspaces config |
mac |
| Lifecycle comments mirrored to Linear Issue | both — mac is the producer, Linear is the durable store |
When the two diverge — e.g. a human transitions a Linear Issue to
Canceled while mac has it running — mac applies its state machine
rules and may cancel the running task with metadata.cancel_reason =
"linear_closed". Mac does NOT override its own terminal states
because Linear changed; once mac says completed, that's final.
6. Schema changes¶
One new table, additive, mirrors beads_repositories:
CREATE TABLE IF NOT EXISTS linear_workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
workspace_url TEXT NOT NULL,
api_key_secret_ref TEXT NOT NULL,
team_filter TEXT NOT NULL DEFAULT '[]',
active_states TEXT NOT NULL DEFAULT '["Todo","In Progress"]',
terminal_states TEXT NOT NULL DEFAULT '["Done","Canceled"]',
state_map TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
poll_interval_seconds INTEGER NOT NULL DEFAULT 30,
last_polled_at TEXT,
last_imported_at TEXT,
last_error TEXT,
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_linear_workspaces_enabled
ON linear_workspaces (enabled, last_polled_at);
CREATE INDEX IF NOT EXISTS idx_linear_workspaces_tenant
ON linear_workspaces (tenant_id);
api_key_secret_ref is a pointer into mac's secrets table — the
actual API key value is encrypted at rest by mac's existing Fernet
mechanism. The bridge dereferences via secrets_service on each
invocation; ephemeral in memory only.
Existing project_items is reused unchanged. New rows for Linear
items use source = "linear:<workspace_id>" and
external_id = <linear_identifier> (e.g. ENG-421). The existing
UNIQUE(source, external_id) index gives us idempotent re-import.
tenants gets one new column via _ensure_column:
The default preserves existing-tenant behavior (beads). Operators flip it explicitly when migrating a tenant to Linear.
7. Dispatch — branching, not abstraction (v2)¶
v2: no TrackerBackend Protocol. Reviewers flagged that
extracting one would require behavior-preserving refactor of ~1500
lines of ControlPlane methods in services.py (the real home of
beads logic — register_beads_repository at services.py:7472,
poll_beads_repositories at services.py:7580,
_sync_beads_database at services.py:8409,
_sync_beads_transition_ledger at services.py:9849, etc.). The
beads code has behaviors (Dolt sync, JSONL drift detection, repo
checkout, required_capabilities filtering) that don't fit cleanly
into the symmetric Protocol I originally sketched. Forcing them
into one would either leak through the abstraction or break existing
behavior.
In v1 we accept ugliness in exchange for safety: dispatch via simple
helper that branches on task.metadata.origin.tracker. There are
4-6 dispatch sites total — one for outbox lifecycle comments, one
for the mac_create_task plugin endpoint, one each for
mac_comment_on_task / mac_update_task_state / mac_link_pr_to_task
/ etc.
# src/mac/tracker_dispatch.py — new file, ~50 lines
def tracker_kind_for_task(task: Task) -> str:
"""Return 'beads' or 'linear' based on origin metadata.
Default 'beads' for backward compatibility with rows imported
before this column existed."""
origin = (task.metadata or {}).get("origin") or {}
return origin.get("tracker", "beads")
def tracker_kind_for_tenant(store: Store, tenant_id: str) -> str:
row = store.query_one("SELECT tracker_kind FROM tenants WHERE id = ?", (tenant_id,))
return (row and row["tracker_kind"]) or "beads"
def emit_lifecycle_comment(cp, task, event_type, payload):
if tracker_kind_for_task(task) == "linear":
cp.linear_bridge.emit_lifecycle_comment(task, event_type, payload)
else:
cp._append_beads_ledger_comment(task, event_type, payload)
def create_issue_for_tenant(cp, tenant_id, title, description, **opts):
if tracker_kind_for_tenant(cp.store, tenant_id) == "linear":
return cp.linear_bridge.create_issue_for_tenant(tenant_id, title, description, **opts)
else:
return cp.import_project_item(...) # existing beads path
That's the entire dispatch layer. Five lines of branching per operation, no new abstraction.
The proper TrackerBackend Protocol is deferred to a follow-up
refactor (let's call it Phase L9) that happens AFTER both bridges
have run in production for a few weeks and we know what the right
shape actually is. By then it's a defensible extraction, not a guess.
What this means for the spec¶
- Phase L4 (originally "TrackerBackend abstraction + tenant binding")
becomes "tenant binding only" —
tenants.tracker_kindcolumn + dispatch helpers. No refactor of beads. - Mac-api endpoints look up
task.metadata.origin.trackerdirectly. - Plugin tool handlers route through
tracker_dispatch.*helpers. - BeadsBridgeService stays untouched. The work in this file becomes zero.
8. LinearClient¶
src/mac/linear_client.py — thin GraphQL wrapper, no state.
class LinearClient:
def __init__(self, api_key: str, *, endpoint: str = "https://api.linear.app/graphql"): ...
def list_teams(self) -> list[JsonDict]: ...
def list_workflow_states(self, team_id: str) -> list[JsonDict]: ...
def list_issues_updated_since(
self,
team_id: str,
since_iso: str,
*,
active_state_names: Sequence[str] = (),
page_size: int = 100,
) -> Iterator[JsonDict]: ...
def get_issue(self, identifier: str) -> Optional[JsonDict]: ...
def create_issue(
self,
team_id: str,
title: str,
description: str = "",
*,
state_id: Optional[str] = None,
priority: Optional[int] = None,
label_ids: Sequence[str] = (),
) -> JsonDict: ...
def create_comment(self, issue_id: str, body: str) -> JsonDict: ...
def update_issue_state(self, issue_id: str, state_id: str) -> JsonDict: ...
def attach_url(
self,
issue_id: str,
url: str,
title: Optional[str] = None,
) -> JsonDict: ...
Implementation:
- Uses Python stdlib
urllibto avoid adding an HTTP dependency. (httpxis already in[postgres]extra; reusing it is fine but not required.) Authorization: <api_key>header (Linear doesn't useBearer).- Retries on 5xx with exponential backoff, max 3 attempts.
- Respects
Retry-Afteron 429. - Returns Linear's GraphQL response wrapped (errors surface as a
LinearApiErrorexception with the response body attached). - All operations are single-statement GraphQL; pagination uses cursor-based GraphQL connections.
No persistent state. One instance per workspace, held by the bridge.
9. LinearBridgeService¶
src/mac/linear_bridge_service.py — implements TrackerBackend.
Field-for-field analog of BeadsBridgeService minus the Dolt /
checkout / JSONL-drift complexity:
class LinearBridgeService:
def __init__(self, store: Store, secrets: SecretsService): ...
# ── workspace management ─────────────────────────────────
def register_workspace(
self,
name: str,
workspace_url: str,
api_key_secret_ref: str,
*,
team_filter: Sequence[str] = (),
active_states: Sequence[str] = ("Todo", "In Progress"),
terminal_states: Sequence[str] = ("Done", "Canceled"),
poll_interval_seconds: int = 30,
tenant_id: Optional[str] = None,
) -> LinearWorkspace: ...
def list_workspaces(
self, *, tenant_id: Optional[str] = None,
) -> list[LinearWorkspace]: ...
def disable_workspace(self, name: str) -> None: ...
def resolve_state_map(self, name: str) -> Dict[str, str]: ...
# Calls list_workflow_states for each configured team; caches in
# linear_workspaces.state_map so future state transitions don't
# need the lookup.
# ── poll + import ────────────────────────────────────────
def poll_workspace(
self, name: str, *, force: bool = False,
) -> PollResult: ...
# ── TrackerBackend impl ──────────────────────────────────
def poll(self, workspace_id: str) -> PollResult: ...
def emit_lifecycle_comment(
self, task: Task, event_type: str, payload: JsonDict,
) -> None: ...
def create_issue(self, workspace_id, title, description, ...) -> CreatedIssue: ...
def attach_pr(self, task: Task, pr_url: str, title=None) -> None: ...
def update_state(self, task: Task, target_state: str, actor: str) -> None: ...
9.1 Poll cycle¶
For each enabled=1 workspace where last_polled_at is older than
poll_interval_seconds:
- Resolve the API key via
secrets_service. - For each
team_idinteam_filter: - GraphQL query:
Issuewhereteam.id == <team_id> AND updatedAt > <last_polled_at>, cursor-paginated. - For each issue returned:
- Compute deterministic mac task id:
task_linear_<identifier_lowercased>(e.g.ENG-421→task_linear_eng_421). - Upsert
tasksrow viaINSERT … ON CONFLICT(id) DO UPDATE, settingmetadata.origin = {tracker:"linear", workspace_id, external_id:<identifier>, url}. - Upsert
project_itemswith(source = "linear:<workspace_id>", external_id = identifier, task_id = task_id). - If the Linear state is in
terminal_statesand the mac task is not in a terminal mac state, schedule a reconcile step (§9.3). - Persist
last_polled_at = utcnow(). - On any GraphQL error, persist
last_errorand surface anintegration_findingsrow (severitywarning).
The first poll for a fresh workspace pulls all issues in
active_states (no since_iso). Subsequent polls are incremental.
9.2 Lifecycle comment emission¶
emit_lifecycle_comment is called by the outbox consumer when a
Linear-origin task transitions state. It posts a Linear comment in
the same pipe-delimited format mac's beads bridge already uses
(verified against services.py:9625):
mac-ledger v1 | task=task_abc | event=state_running | actor=agent_xyz | lease=lease_xyz
mac-ledger v1 | task=task_abc | event=state_needs_review | actor=agent_xyz | evidence=evidence_xxx
mac-ledger v1 | task=task_abc | event=published | actor=agent_xyz | target=git://main | pr=<url>
mac-ledger v1 | task=task_abc | event=state_failed | actor=agent_xyz | reason=verification_failed
Format rationale: operator continuity with beads (same parser works for both), grep-friendly, machine-parseable. Linear renders pipe characters fine in markdown comments.
Mirrored events (same set beads uses; see
production-deployment.md §Beads Human Ledger): imported,
claimed, state_running, state_needs_review, state_reviewing,
state_failed, state_cancelled, state_open, evidence_added,
review_requested, review_completed, published, retry_reopened,
retry_exhausted.
Lease renewals are NOT mirrored (would flood the issue thread).
9.3 Terminal-state reconciliation¶
When a human moves a Linear Issue to a terminal state while mac has the corresponding task running:
- mac runs the existing
cancel_taskpath withreason="linear_closed"andactor="linear:<external_id>". - The active lease is invalidated, the running Job receives SIGTERM (mac-k8s-runner sees the cancelled state on the next poll).
- A
mac-ledger v1: state_cancelled — closed in Linearcomment goes back to the same Linear Issue for audit clarity.
If mac is already in a terminal state when Linear closes the Issue, no action — mac's terminal state wins.
10. Auto-register on mac-api startup¶
Following the same declarative pattern used for the K8s register-mac-identity
init container.
mac.api.create_app startup hook calls
_maybe_register_linear_workspace_from_env. Env contract:
| Variable | Purpose | Required when |
|---|---|---|
MAC_LINEAR_BRIDGE_ENABLED |
On-switch; "1" to enable |
always |
LINEAR_API_KEY |
Linear API token (from mac-secret) | bridge enabled |
MAC_LINEAR_WORKSPACE_NAME |
Workspace name in mac's table | optional (default default) |
MAC_LINEAR_WORKSPACE_URL |
linear.app/<slug> for context |
bridge enabled |
MAC_LINEAR_TEAMS |
CSV team slugs to poll, e.g. eng,ops |
bridge enabled |
MAC_LINEAR_TENANT_ID |
Bind this workspace to a tenant | optional |
MAC_LINEAR_ACTIVE_STATES |
JSON array | optional (default ["Todo","In Progress"]) |
MAC_LINEAR_TERMINAL_STATES |
JSON array | optional (default ["Done","Canceled"]) |
MAC_LINEAR_POLL_INTERVAL_SECONDS |
Default 30 |
optional |
MAC_LINEAR_BRIDGE_ON_HEARTBEAT |
"1" to drive polling from hub heartbeat |
optional |
Implementation (v2 — rewritten to use real SecretsService API):
def _maybe_register_linear_workspace_from_env(cp: ControlPlane) -> None:
if not _env_bool("MAC_LINEAR_BRIDGE_ENABLED"):
# v2: env-driven disable — reconcile DOWN. If a workspace was
# auto-registered previously, mark it disabled so polling stops.
cp.linear_bridge.disable_env_managed_workspace_if_present()
return
api_key = os.environ.get("LINEAR_API_KEY", "").strip()
if not api_key:
log.warning("MAC_LINEAR_BRIDGE_ENABLED=1 but LINEAR_API_KEY unset; skipping")
return
name = os.environ.get("MAC_LINEAR_WORKSPACE_NAME", "default")
workspace_url = os.environ.get("MAC_LINEAR_WORKSPACE_URL", "").strip()
teams = _csv(os.environ.get("MAC_LINEAR_TEAMS", ""))
if not workspace_url or not teams:
log.error("MAC_LINEAR_BRIDGE_ENABLED=1 but workspace_url/teams not configured")
return
# v2: secret upsert via get-or-create-or-rotate. SecretsService has
# create_secret (rejects if name exists) and rotate_secret (rejects
# if name doesn't exist), so we branch on existence.
secret_name = f"linear-{name}-api-key"
existing = cp.secrets_service.find_by_name(secret_name) # NEW helper
api_key_hash = sha256(api_key.encode()).hexdigest()
if existing is None:
secret = cp.secrets_service.create_secret(
name=secret_name,
value=api_key,
scopes={"system": "linear", "workspace": name},
created_by="mac-startup",
)
secret_id = secret.id
else:
secret_id = existing.id
# Only rotate when the env-provided key materially changed.
# Hash comparison avoids gratuitous rotation audit churn on
# every restart. Note: requires a stored hash; either add
# secrets.value_hash column (preferred) or reveal+compare
# (uses an audit slot, less elegant).
stored_hash = (existing.metadata or {}).get("value_sha256")
if stored_hash != api_key_hash:
cp.secrets_service.rotate_secret(
secret_id,
api_key,
actor="mac-startup",
metadata_patch={"value_sha256": api_key_hash},
)
# v2: explicit upsert with config-hash. If env config differs from
# DB row, env wins; emit a finding so operators see drift.
desired = {
"workspace_url": workspace_url,
"api_key_secret_ref": secret_id,
"team_filter": teams,
"active_states": _json_arg("MAC_LINEAR_ACTIVE_STATES", ["Todo", "In Progress"]),
"terminal_states": _json_arg("MAC_LINEAR_TERMINAL_STATES", ["Done", "Canceled"]),
"poll_interval_seconds": int(os.environ.get("MAC_LINEAR_POLL_INTERVAL_SECONDS", "30")),
"tenant_id": os.environ.get("MAC_LINEAR_TENANT_ID") or None,
"enabled": 1,
"env_managed": 1, # marker: this row is owned by env, not CLI
}
existing_ws = cp.linear_bridge.find_workspace_by_name(name)
if existing_ws is None:
cp.linear_bridge.register_workspace(name=name, **desired)
else:
diff = _diff_workspace_config(existing_ws, desired)
if diff:
cp.linear_bridge.update_workspace(name=name, **desired)
cp.record_integration_finding(
source_id=existing_ws.id,
source_kind="linear_workspace",
finding_type="config.env_overrode_db",
severity="info",
title="Linear workspace config updated from env",
detail={"changed_fields": list(diff.keys())},
)
# Refresh state_map every startup. Cheap (one GraphQL call per team).
try:
cp.linear_bridge.refresh_state_map(name)
except Exception as exc:
log.warning("Linear state map resolution failed: %s", exc)
cp.record_integration_finding(
source_id=name,
source_kind="linear_workspace",
finding_type="state_map.refresh_failed",
severity="warning",
title="Linear state_map refresh failed at startup",
detail={"error": str(exc)},
)
log.info("auto-registered Linear workspace %s (teams=%s)", name, teams)
New helpers required (small, low-risk additions):
SecretsService.find_by_name(name) -> Optional[Secret]— read-only, no audit side effect. ReturnsNonewhen absent.SecretsService.rotate_secretalready exists; v2 just uses it through the documented path.linear_bridge.find_workspace_by_name,update_workspace,disable_env_managed_workspace_if_present— bridge-level helpers.
Multi-replica safety: linear_workspaces.name is UNIQUE. Concurrent
replicas race for the INSERT or UPDATE; SQL atomicity ensures exactly
one final row. The desired map is identical across replicas at any
moment, so racing replicas converge regardless of order.
The env_managed=1 marker keeps env-managed workspaces separate from
CLI-managed ones. Operators using the imperative CLI for additional
workspaces get rows with env_managed=0 that the auto-register
function leaves alone.
11. mac-api endpoints¶
New routes added to api.py. All require existing auth (admin for
workspace mgmt; agent for runtime calls).
POST /bridges/linear/workspaces register
GET /bridges/linear/workspaces list
GET /bridges/linear/workspaces/{name} detail
POST /bridges/linear/workspaces/{name}/poll manual trigger
DELETE /bridges/linear/workspaces/{name} disable
GET /bridges/linear/workspaces/{name}/teams list Linear teams
GET /bridges/linear/workspaces/{name}/states list workflow states
POST /tasks/{id}/linear/comment body: {body}
POST /tasks/{id}/linear/state body: {target_state}
POST /tasks/{id}/linear/attach body: {url, title?}
The /tasks/{id}/linear/* routes are tracker-agnostic by design — they
look up task.metadata.origin.tracker and route through
get_tracker_for_origin. Calling them on a beads-origin task returns
422 (tracker mismatch — task is beads-origin).
The existing POST /persona-instances/{id}/tasks endpoint gains a
Linear-routing branch: when the calling Hermes instance's tenant is
Linear-backed, mac-api creates the Linear Issue first, then the mac
task row, in one transaction. The response includes both ids.
12. CLI surface¶
Mirroring beads:
mac admin bridge linear register <name> --workspace-url ... --api-key-secret <id>
--team <team> [--team <team>...]
[--tenant <tenant_id>]
[--active-states JSON] [--terminal-states JSON]
[--poll-interval-seconds 30]
mac admin bridge linear list [--tenant <tenant_id>]
mac admin bridge linear show <name>
mac admin bridge linear poll [--workspace <name>] [--force]
mac admin bridge linear disable <name>
mac admin bridge linear list-teams <name>
mac admin bridge linear list-states <name>
mac admin bridge linear resolve-state-map <name> # refresh state_map cache
mac admin tenant set-tracker <tenant_id> <beads|linear> [--workspace <name>]
And mac-hermes:
mac-hermes linear-workspaces
mac-hermes register-linear-workspace ...
mac-hermes poll-linear-workspaces ...
The imperative paths exist as escape hatches for multi-workspace setups (one per tenant) that env-based auto-register can't express, and for ad-hoc operations (manual re-poll, list states after a Linear workflow change).
13. Hermes plugin updates (v2)¶
The mac-hermes-plugin gains six new tools (was three; architect review flagged that comment/state/link_pr alone leaves the LLM without normal triage operations).
| Tool | mac-api route | Purpose |
|---|---|---|
mac_comment_on_task |
POST /tasks/{id}/linear/comment |
Free-form comment the human will read |
mac_update_task_state |
POST /tasks/{id}/linear/state |
Move issue to a named workflow state |
mac_link_pr_to_task |
POST /tasks/{id}/linear/attach |
Attach a PR URL with optional title |
mac_set_task_priority |
POST /tasks/{id}/linear/priority |
Adjust priority when work is more/less urgent than initially scoped |
mac_add_task_label |
POST /tasks/{id}/linear/label |
Add a label (e.g. needs-human-review, security, blocked) — primary triage signal in Linear |
mac_assign_task |
POST /tasks/{id}/linear/assignee |
Assign to a Linear user when escalating to a human |
Each route enforces tenant scope via principal.assert_tenant. Each
route returns 422 with tracker mismatch — task is beads-origin when
called on a non-Linear task (the LLM tool description tells the LLM
to handle 422 gracefully rather than retry).
Server-side dispatch via tracker_dispatch.* helpers (§7). The LLM
never picks the tracker; the task's origin metadata does.
13.1 Files touched per tool¶
v2 correction — provides_tools lives in plugin/plugin.yaml,
NOT plugin/manifest.py. Adding each tool requires edits to:
plugin/plugin.yaml— append the tool name toprovides_toolsplugin/manifest.py— append aToolSpectoTOOLSplugin/schemas.py— add a JSON Schema for the argsplugin/__init__.py— potentially add body-shaping logic if the tool needs to translate LLM-friendly args into the API shape (most tools route through_handle_tooland don't need this)
After source edits: rebuild mac-hermes-plugin image, push, bump
tag in home-ops/components/ai/hermes-agent/values.yaml, ArgoCD
syncs, hermes-agent re-rolls and the new tools become available to
the LLM at next session start.
13.2 mac_create_task routing¶
Existing mac_create_task LLM-facing schema is unchanged. The
mac-api endpoint behind it (/persona-instances/{id}/tasks) gains a
Linear-routing branch based on the calling Hermes instance's tenant
tracker_kind:
tracker_kind = 'beads'→ existing behavior (direct mac task creation, optionally bridged to beads via the bridge poller)tracker_kind = 'linear'→ mac-api creates the Linear Issue viaLinearClient.create_issue, then creates the mirroring mac task row in the same transaction withmetadata.origin = {tracker: 'linear', workspace_id, external_id, url, identifier}
The response payload gains linear_identifier and linear_url
fields when routed through Linear, both None for beads. The
plugin's mac_create_task handler forwards them to the LLM so it
can speak the friendly ENG-421 identifier and the URL.
14. Per-tenant tracker selection¶
tenants.tracker_kind (column added via _ensure_column):
'beads'(default) → tasks created from this tenant flow to beads'linear'→ tasks flow to Linear
A tenant bound to 'linear' must also have at least one
linear_workspaces.tenant_id = <tenant_id> row (or
tenant_id = NULL shared workspaces — TBD, see §17).
CLI: mac admin tenant set-tracker <tenant_id> linear --workspace acme-eng.
The validation:
- Setting
tracker_kind='linear'with no matching workspace row is rejected at the mac-api layer with 422. - Setting
tracker_kind='beads'with nobeads_repositoriesis permitted (a tenant can have a tracker mode without a configured source, useful during initial setup).
15. Operational concerns¶
15.1 Rate limits¶
Linear's GraphQL API has soft limits documented at ~1500 req/min for
authenticated users. At 30s poll cadence with one workspace and a
handful of teams, normal polling load is well under 10 req/min. The
risk is bursty agent-driven writes (many concurrent
mac_comment_on_task calls during a fleet operation). Mitigation:
LinearClienthonorsRetry-Afteron 429- The outbox consumer is single-threaded per backend so comments are serialized
- Bridge logs rate-limit responses to observability stream for visibility
If we ever hit a real rate-limit problem, the answer is webhooks (Phase L4) which eliminate the poll traffic entirely.
15.2 Retry strategy (v2)¶
v2: the existing outbox does NOT have backoff or auto-retry — this must be fixed in Phase L0 before any Linear work ships.
Today's task_transition_outbox schema
(src/mac/data/postgres/schema.sql:206):
CREATE TABLE task_transition_outbox (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
from_state TEXT,
to_state TEXT,
detail TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
processed_at TEXT
);
No next_attempt_at, no max_attempts, no last_error, no
dead_letter status. The drain loop
(task_lifecycle.py:list_outbox(status="pending")) picks all pending
rows; mark_outbox_failed flips status to "failed" which removes
the row from future drains. There is no retry — a single Linear API
hiccup permanently strands a lifecycle comment.
Beads escapes this today only because _run_bd_for_task (services.py:
9549) silently swallows exceptions and returns False — the outbox
drain sees no exception, marks the row processed, and the failed
ledger comment is silently lost. (Pre-existing bug, separate fix.)
Phase L0 extends the outbox to support proper retry:
ALTER TABLE task_transition_outbox
ADD COLUMN next_attempt_at TEXT, -- NULL = ready now
ADD COLUMN last_error TEXT,
ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 5;
-- 'pending' | 'processing' | 'processed' | 'dead_letter'
-- ('failed' is renamed to 'dead_letter' via migration data step)
Drain query becomes:
SELECT * FROM task_transition_outbox
WHERE status = 'pending'
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
ORDER BY created_at
LIMIT ?
mark_outbox_failed(id, error, retriable: bool) semantics:
- If retriable AND attempts < max_attempts: increment attempts,
set next_attempt_at = now + backoff(attempts), set last_error,
status stays pending.
- If retriable AND attempts >= max_attempts: status → dead_letter,
set last_error, emit integration_findings row.
- If NOT retriable (permanent error, e.g. 4xx other than 429):
status → dead_letter immediately, emit finding.
backoff(attempts) is exponential with jitter:
min(60 * 2^attempts, 3600) * (0.5 + random()). Max 1h between
retries.
For Linear specifically:
- 429 with Retry-After: N → retriable, override
next_attempt_at = now + N.
- 5xx → retriable, normal backoff.
- 401/403 → not retriable, dead-letter immediately + alert.
- 404 (issue deleted from Linear) → not retriable, mark task
origin-orphaned, emit finding.
- 422 (validation error from Linear) → not retriable, dead-letter +
surface to operator.
- Network timeouts → retriable.
This change benefits beads too: once the swallowed-exceptions bug is
fixed in _run_bd_for_task, beads ledger comments get the same
retry behavior for free.
15.2.1 Migration plan for L0¶
The schema change is additive (new columns) and the status enum gains
dead_letter but keeps failed as a deprecated alias for one
release cycle. Concrete steps:
- Add
next_attempt_at,last_error,max_attemptscolumns (additive, both backends via_ensure_column+ schema.sql edit). - Backfill:
UPDATE task_transition_outbox SET max_attempts = 5 WHERE max_attempts IS NULL(no-op given DEFAULT, but explicit). - Update
TaskLedgerService.mark_outbox_failedto take aretriablebool and apply the new state-transition logic. - Update the drain query everywhere it appears.
- Backfill existing
status='failed'rows:UPDATE … SET status='dead_letter' WHERE status='failed'.
No data loss; no downtime; old code reading the table without the new columns is fine because they're nullable.
15.3 Drift detection¶
The 30s poll catches Linear-side changes. To catch mac-vs-Linear drift
(e.g. mac thinks task is running but Linear shows Done), the poll
loop performs a periodic reconcile pass: every Nth tick (default 10,
so every ~5 min), it pulls the FULL current state of all open
issues in team_filter and compares against mac's view. Discrepancies
become integration_findings rows for operator review.
15.4 Secret lifecycle¶
LINEAR_API_KEY is stored encrypted in mac's secrets table (Fernet,
same as other mac secrets). The on-disk Secret in K8s is the source;
mac re-imports on every auto-register. Rotation = update 1Password →
ExternalSecret resyncs → mac-api restart re-imports.
The API key is bound to one Linear bot user. Operators should create a dedicated bot user (not a person's personal token) so leaves / permission changes don't break the bridge.
16. Testing strategy¶
16.1 Unit tests (no live Linear)¶
LinearClient: HTTP layer mocked withhttpx.MockTransport, asserts the GraphQL queries we send and the error/retry handling. ~15 tests.LinearBridgeService.poll_workspace: fakeLinearClientreturning scripted issue lists, assertstasksandproject_itemsupserts happen idempotently across re-runs. ~10 tests.LinearBridgeService.emit_lifecycle_comment: fake client, asserts the right markdown body shape per event type. ~10 tests.TrackerBackendprotocol conformance: bothBeadsBridgeServiceandLinearBridgeServicemust pass the same shared contract test suite. ~15 tests.
16.2 Live-Linear tests (gated)¶
Following the pytest.mark.postgres pattern:
pytest.mark.linearmarkerMAC_TEST_LINEAR_API_KEY+MAC_TEST_LINEAR_TEAMenv vars- Tests create/list/comment/delete issues in a dedicated test team
- Skipped by default; CI enables them only on protected branches with a sandbox Linear workspace
- ~10 tests covering the full create → poll → import → comment back → state transition loop
16.3 End-to-end test in dev cluster¶
Manual: register a sandbox Linear workspace, create an Issue, watch it appear in mac within 30s, claim it from the runner, see lifecycle comments land on the Linear Issue, close the Issue from Linear, watch mac cancel the running task.
17. Open questions¶
-
Workspace-tenant cardinality. Can a single
linear_workspacesrow serve multiple tenants (settenant_id = NULL, partition by Linear team labels)? Or strictly 1:1? Spec assumes 1:N (one workspace, many tenants via team filter) since it's strictly more expressive. Worth confirming before code. -
State map refresh cadence. State_map is cached on first register + refreshed manually via CLI. Should it auto-refresh on every poll (cheap, one extra GraphQL call) or only when the bridge sees an unknown state name? Spec assumes the latter (lazy refresh).
-
Backfill of beads-origin tasks. Spec says "no backfill, new tasks only". Should we offer an explicit CLI
mac bridge linear import-from-beadsfor operators who want it? Probably yes as a separate utility; not part of v1. -
Plugin tool granularity. Spec exposes three new tools (
comment,update_state,link_pr). Symphony exposes one tool (linear_graphql— raw GraphQL) and lets the LLM compose. Trade-off: three tools is safer + more obvious to the LLM; one tool is more powerful + more error-prone. Spec picks three; flag for review. -
Workflow runs. Mac's
workflowstable models DAG-of-tasks. Linear has parent-child Issues. Should DAG workflows mirror as Linear parent + sub-issues? Defer to a Phase L5 — first version handles flat issues only.
18. Risks¶
| Risk | Mitigation |
|---|---|
| Linear API key leakage | Key encrypted in mac.secrets; never logged; rotated via 1Password |
| Linear API outage breaks mac entirely | Bridge failures are caught; mac continues with cached state; outbox retries writes |
| Drift between mac state and Linear state | Periodic reconcile pass (§15.3); integration_findings surface drift |
| TrackerBackend retrofit of beads regresses existing flows | Shared contract test suite; both implementations must pass identical tests |
| LLM picks wrong tool (e.g. comment instead of update_state) | Clear tool descriptions; mac-api validates intent server-side |
| Multi-replica race on auto-register | linear_workspaces.name UNIQUE enforces single winner; ON CONFLICT no-op |
| State_map staleness if operator renames Linear states | CLI resolve-state-map to refresh; bridge logs warning on unknown state |
| Beads users feel pressured to migrate | Beads stays fully supported; no deprecation message |
19. Implementation phasing (v2)¶
Each phase is independently mergeable; later phases assume earlier phases shipped.
Phase L0 — Extend the outbox for proper retry¶
Prerequisite for all Linear work. Without this, the first Linear API blip silently strands lifecycle comments.
- Add
next_attempt_at,last_error,max_attemptscolumns totask_transition_outbox - Add
dead_letterstatus; data-migrate existing'failed'rows - Rewrite
TaskLedgerService.mark_outbox_failedto take aretriable: booland apply backoff - Update drain query to honor
next_attempt_at - Add
SecretsService.find_by_name(name) -> Optional[Secret]helper (needed by auto-register; clean to add here) - Tests: outbox round-trips with simulated 429/5xx/4xx; backoff
monotonicity; dead_letter triggers
integration_findings
After this phase: outbox is production-grade for any backend. Beads
also benefits (once the silent-exception-swallow bug in
_run_bd_for_task is fixed separately).
Phase L1 — Core LinearClient + workspace table¶
src/mac/linear_client.pywith HTTP/GraphQL opslinear_workspacestable migration (idempotent CREATE TABLE IF NOT EXISTS)- Unit tests for client + mocked GraphQL transport
- CLI:
mac admin bridge linear list-teams,list-states(read-only operations, no behavior change in mac)
No production effect — operators can probe their Linear workspace without mac doing anything new.
Phase L2 — LinearBridgeService (poll + import)¶
LinearBridgeServicewithregister_workspace,list_workspaces,poll_workspacetasks.metadata.origin.tracker = "linear"import path- CLI:
mac admin bridge linear register|list|show|poll|disable - Hub-heartbeat poll trigger (
MAC_LINEAR_BRIDGE_ON_HEARTBEAT=1) - Unit tests for poll cycle
After this phase: an operator can register a Linear workspace and see issues import into mac as tasks. No outbound writes yet.
Phase L3 — Lifecycle comments out (outbox)¶
LinearBridgeService.emit_lifecycle_commentwired intotask_transition_outboxconsumer- Linear-origin tasks emit
mac-ledger v1:comments on state transitions
After this phase: round-trip works — issues flow Linear → mac → Linear (as comments).
Phase L4 — Tenant binding (v2: no Protocol)¶
tenants.tracker_kindcolumn added via_ensure_columnsrc/mac/tracker_dispatch.py— thin helpers (~50 lines):tracker_kind_for_task,tracker_kind_for_tenant,emit_lifecycle_comment,create_issue_for_tenant- CLI:
mac admin tenant set-tracker <tenant> <beads|linear> [--workspace <name>] mac_create_taskplugin endpoint dispatches via the helpers- Validation: setting
tracker_kind='linear'requires either matchinglinear_workspaces.tenant_idrow OR a shared workspace (tenant_id IS NULL)
After this phase: per-tenant tracker selection works. No retrofit of
beads code; no TrackerBackend Protocol in this phase.
A proper TrackerBackend Protocol extraction is deferred to Phase
L9 (not in v1 scope) — done after both bridges have run in
production for several weeks and the right abstraction shape is
evidence-driven rather than guessed.
Phase L5 — Auto-register from env¶
_maybe_register_linear_workspace_from_envstartup hook- Env-var contract documented in production-deployment.md
- home-ops side: env config + ExternalSecret update
After this phase: declarative deploy; no imperative bootstrap needed for the single-workspace case.
Phase L6 — Hermes plugin extensions¶
mac_comment_on_task,mac_update_task_state,mac_link_pr_to_taskadded toplugin/manifest.py- New mac-api endpoints
- Plugin image rebuild + home-ops tag bump
After this phase: agents can write back to Linear during conversations.
Phase L7 — Terminal-state reconciliation¶
- Periodic reconcile pass per §15.3
integration_findingsfor drift detection- Cancellation flow when Linear closes a running Issue
After this phase: bidirectional state sync is robust.
Phase L8 — Docs + operator readiness¶
production-deployment.md§Linear Bridge (mirrors the current repository registry and legacy-import framing)linear-bridge-runbook.md: rotation, troubleshooting, common failure modes- README updates
Phases L1-L3 are minimum viable. Phases L4-L8 polish + production.
21. Observability (added in v2)¶
Each new module surfaces metrics, logs, and findings through mac's
existing ObservabilityService so operators see Linear health in the
same dashboard surface as the rest of mac.
21.1 Metrics¶
| Metric | Unit | Source |
|---|---|---|
linear.poll.duration_ms |
ms | per poll_workspace invocation |
linear.poll.issues_imported |
count | per poll (new + updated) |
linear.poll.success |
bool | per poll |
linear.graphql.duration_ms |
ms | per LinearClient call, with operation_name in detail |
linear.graphql.rate_limit_remaining |
count | from X-RateLimit-Remaining header |
linear.outbox.queue_depth |
count | gauge — pending Linear-targeted rows |
linear.outbox.dead_letter_total |
counter | running count of dead-lettered rows |
21.2 Logs¶
linear.workspace.registered— info, on register/updatelinear.workspace.disabled— info, when env reconciles downlinear.poll.failed— error, with truncated error messagelinear.write.failed— warn, with retry-eligibilitylinear.write.dead_lettered— error, withtask_id,event_type,last_error
21.3 Findings (via record_integration_finding)¶
source_kind = "linear_workspace", with the following
finding_types:
config.env_overrode_db(info) — startup detected env driftstate_map.refresh_failed(warning) — bridge can't reach Linearpoll.stalled(critical) —last_polled_atolder than5 × poll_interval_secondsauth.invalid(critical) — 401/403 from Linearstate.unknown_in_linear(warning) — Linear has a state not inactive_states/terminal_statesconfigtask.linear_terminal_while_running(info) — reconcile detected Linear close mid-run; mac is cancelling
21.4 Alerts (operator-defined; spec lists the conditions)¶
poll.stalledfor >10 min → pageauth.invalid→ pageoutbox.dead_letter_totalincreases >0 in 5min → notify
22. Tenant deletion semantics (added in v2)¶
Today, linear_workspaces.tenant_id REFERENCES tenants(id) ON DELETE
CASCADE would cascade the workspace row when a tenant is deleted.
That's the right default but leaves:
- The encrypted API key in
secrets— orphaned. Cleanup required. - Already-imported mac tasks with
metadata.origin.tracker = "linear"pointing at the deleted workspace_id — orphaned. Lifecycle comments would fail. - The
project_itemsrows undersource = "linear:<workspace_id>"— cascade-deleted viatask_idFK when the parent task is deleted, but the project_items survive if the task survives.
v2 specifies the cleanup contract:
cp.delete_tenant(tenant_id)raises if anylinear_workspaces.tenant_id = tenant_idrows exist with non-completed work. Operator mustmac bridge linear disable <name>first and drain in-flight tasks.- After disable, deletion cascades:
linear_workspacesrow deleted- Secret marked retired (
secret.enabled = 0); fully purged via existing secret-cleanup retention path - Existing tasks with
origin.tracker = "linear"get a new metadata keyorigin.deleted_workspace = trueso the dispatch helper can skip Linear writes - Forensic findings recorded so the audit trail survives the delete.
ON DELETE CASCADE on the schema is correct as the default for
operator force-deletes; the validation gate in
delete_tenant is the safe-by-default path.
23. Auth and scope (added in v2)¶
The new endpoints must explicitly extend mac-api's
_required_scope(method, path) in api.py:943. Today that function
has no case for /bridges/ — non-GET requests fall through to the
catch-all "write". v2 adds:
def _required_scope(method, path):
...
if path.startswith("/bridges/"):
return "admin" # all bridge mgmt requires admin
if (
"/linear/comment" in path
or "/linear/state" in path
or "/linear/attach" in path
or "/linear/label" in path
or "/linear/priority" in path
or "/linear/assignee" in path
):
return "agent" # runtime tools called by mac-task-runner
...
The assert_tenant check in each handler ensures the bearer can act
on the resource's tenant. For agent-scoped calls on Linear-origin
tasks, the agent's bound tenant must match the task's tenant.
For Linear-side auth: v2 specifies personal API key tied to a dedicated bot user in the Linear workspace. Rationale:
- Linear OAuth apps require an app-publishing flow that's heavy for a self-hosted bridge
- Personal API keys are workspace-scoped — sufficient for our case
- The bot user provides identity for audit ("created by mac-bridge")
- Token rotation = update 1Password → ExternalSecret resyncs → mac re-registers via the auto-register code path
Future work: support Linear App API keys (scoped tokens) when Linear exposes them widely; OAuth flow for multi-org SaaS deployments.
24. Acceptance criteria for v1 (phases L0–L6)¶
A new Linear-backed tenant can:
- Be created with
mac admin tenant set-tracker acme linear --workspace acme-eng - Have a Linear Issue created from the Linear UI appear as a mac task within 60s
- Receive lifecycle comments on the Linear Issue as the mac task moves through claim/run/review/complete states
- Have Hermes successfully call
mac_create_taskand produce a new Linear Issue + mac task - Have an agent successfully call
mac_comment_on_taskmid-execution and see the comment appear in Linear
All five must work end-to-end against a real Linear workspace before v1 is considered shippable.