Skip to content

feat(operator): containment set + dual-writer deny rule (OPS-1214) - #136

Merged
todie merged 2 commits into
mainfrom
feat/operator-containment
Aug 16, 2026
Merged

feat(operator): containment set + dual-writer deny rule (OPS-1214)#136
todie merged 2 commits into
mainfrom
feat/operator-containment

Conversation

@todie

@todie todie commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The rebuild gate for the in-cluster operator, per the operator-ratified ecosystem plan (2026-08-16) and the OPS-1214 pause+rebuild ruling.

What

  • HOLD switchLINEARCTL_HOLD=1 or hold-file existence (default /etc/linearctl/hold/HOLD, mounted from an optional <release>-hold ConfigMap) pauses role runs, queue polling (messages stay queued — no pull-then-ack), and /delegate (503). The mechanical handle for the estate hold-batch-ops SOP; flips without a redeploy.
  • Mutation budget — per-run cap on issue writes (LINEARCTL_MUTATION_BUDGET, default 10); truncation narrated with exact dropped counts. Dry-run previews the same capped slice an apply would take, so the log tells the truth about apply behavior.
  • Rate-limit preflight — the operator wires a per-tick preflight (HOLD + Linear quota probe) into the role scheduler; exhausted quota skips the tick, probe failure fails open. Bare scheduleRole keeps a sync HOLD-only gate — no network in tests.
  • Dual-writer deny rule (plan §2b2)soma-ingest-labeled issues are read-only to this operator entirely (soma keys idempotency on updatedAt; even additive comments perturb it). Enforced at the single guardrail checkpoint (targetLabels + partitionDeniedTargets); the stale sweep now carries label names; grooming partitions before labeling and logs the denied slice; intake-triage checks its comment target's live labels.
  • Chart 0.3.0 — optional hold ConfigMap mount.

Verification

  • bun run typecheck clean; bun test 500 pass / 0 fail (new: containment, scheduler-preflight suites).
  • helm lint clean.

Write-enablement gating note: this PR merging is one of the three redeploy conditions (containment merged + deny rule + writer-inventory fold into the split doc). Redeploy itself remains operator-gated.

The rebuild gate for the in-cluster operator (ecosystem plan 2026-08-16,
operator-ratified):

- HOLD switch: LINEARCTL_HOLD=1 or a hold file (default
  /etc/linearctl/hold/HOLD, ConfigMap-mounted) pauses role runs, queue
  polling (messages stay queued, no pull-then-ack), and /delegate (503).
  Mechanical handle for the estate hold-batch-ops SOP — no redeploy needed.
- Mutation budget: per-run cap on issue writes (LINEARCTL_MUTATION_BUDGET,
  default 10). Truncation is narrated with exact dropped counts, never
  silent. Dry-run previews the same capped slice an apply would take.
- Rate-limit preflight: the operator's role scheduler probes Linear quota
  before each run (fetchRateLimit now takes a full Authorization value so
  OAuth actors can probe); exhausted quota skips the tick, probe errors
  fail open. Bare scheduleRole keeps a sync HOLD-only gate (no network).
- Dual-writer deny rule (plan section 2b2, soma split): issues labeled
  soma-ingest (extendable via LINEARCTL_DENY_LABELS) are read-only to this
  operator entirely. Enforced at the single guardrail checkpoint via
  ProposedAction.targetLabels + partitionDeniedTargets for batches; the
  stale sweep now carries label names (labels selected in the flat issues
  query) and grooming partitions before labeling; intake-triage checks its
  comment target's live labels before posting.
- Chart 0.3.0: optional <release>-hold ConfigMap mounted read-only at
  /etc/linearctl/hold.
…ied findings)

- Hold ConfigMap is no longer chart-managed: a chart-rendered `data: {}`
  is reverted by any sync/self-heal, silently RELEASING an engaged hold
  mid-incident. The mount stays optional; the ConfigMap is operator-
  created (untracked by GitOps, survives sync). Engage/release documented
  in the deployment template.
- Deny partition moved INTO applyStaleLabel (the write layer): the
  operator role, `linearctl stale --apply`, and future callers exclude
  deny-labeled issues by construction; denied identifiers are returned
  and surfaced by every caller. The watch path's state move
  (moveToStartedIfDelegated) now label-checks too — a deny-labeled
  delegated issue gets session activities only, no issue mutation.
- LINEARCTL_DENY_LABELS now EXTENDS the built-in set instead of
  replacing it — an empty or typo'd deploy value can no longer silently
  drop the partition. Label comparison trims + lowercases.
- LINEARCTL_HOLD_FILE="" falls back to the default path (was: silently
  disabled the file hold — existsSync("") is always false).
- intake-triage's comment path degrades gracefully: budget-zero, denied
  target, or fetch failure skips ONLY the comment and keeps the
  read-only summary (was: three throw sites made every cadence fail
  forever under the documented write-freeze).
- /readyz stays green while held (heartbeat maintained; `held: true`
  surfaced) — a hold reads as held, not as an outage, and rollouts
  during a hold can pass readiness.
- Scheduler: bare scheduleRole is now explicitly UNGATED (pass-through
  default, honest JSDoc — the daemon wires the full preflight); `stopped`
  re-checked after the async preflight so SIGTERM during the quota probe
  cannot launch a fresh mutating run.
- Comments reworded to public-repo-appropriate terms: internal plan
  sections and program narrative removed; the partition rationale now
  cites docs/funnel-contract.md (already public) only.
@todie
todie merged commit 813c9a8 into main Aug 16, 2026
4 of 6 checks passed
todie added a commit that referenced this pull request Aug 16, 2026
CF API tokens for Queues may scope account-wide (per-queue resource
scoping unverified — runbook step 3 carries the caveat), so a producer
token could forge AgentSessionEvents into linear-agent-events. Defense:
the webhook receiver signs every body at enqueue; the operator verifies
before processing, so queue-level write access alone cannot reach the
event loop.

- src/core/hmac-envelope.ts: v1 envelope {v, alg, ts, sig, body}; sig =
  HMAC-SHA256(key, "v1\n"+audience+"\n"+ts+"\n"+body). Audience (queue
  name) binding kills cross-queue replay; the MAC-covered timestamp
  bounds same-queue replay to a 10-min window. Fixed-set failure reasons
  (no attacker-controlled interpolation), 64-hex sig format check before
  any hashing, constant-time digest compare. Replay WITHIN the window by
  a queue-read-capable principal remains possible — documented v1 limit.
- Poller: key/prev-key/mode resolved ONCE at startup (injectable via
  OperatorOptions.hmac); SET-but-empty key refuses startup; posture
  logged at startup and surfaced in /readyz (hmac: off|warn|enforce);
  "warn" migration mode processes non-verifying messages with an
  UNVERIFIED log line (closes the rollout-window reject+ack data-loss
  trap); LINEARCTL_QUEUE_HMAC_KEY_PREV accepted during rotation.
- Control socket hardened to 0600 (dir 0700) — the /delegate trust
  boundary is filesystem perms, not umask luck.
- Chart: queueHmacKey/queueHmacKeyPrev/queueHmacMode env (guarded);
  values.yaml documents the 4-step rollout order + rotation. Runbook:
  step-3 scoping caveat, step-4 env + envelope format, troubleshooting
  rows for every rejection reason.

Rebuilt as one commit on post-#136 main (supersedes 85867b2+a4959db;
review findings from the 8-angle pass are incorporated).
@todie
todie deleted the feat/operator-containment branch August 16, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant