Mission: journal-project-consent-3030-01KYKWQS · Branch: feat/journal-project-consent-3030 Spec: spec.md · Plan: plan.md

> Containment wave first (WP01, WP02), and it needs no migration. The leak is in the journal > dispatcher (delivery/dispatcher.py:_select_undelivered over journal.read_all()), not > sync/batch.py. WP01 makes the leak loud and also unblocks saas#585; WP02 deletes an entire second > leaking drain. Only then the durable wave (WP04–WP06) makes delivery correct. > > WP01 is bounded to mixed batches. The cross-project refusal only fires when a selected batch > spans projects. Because selection is FIFO and limit-bounded, a homogeneous window of one > non-consented project still ships. Correct per-project delivery arrives with WP06 — do not treat WP01 > as containment for the incident population. > > The enabler is load-bearing and easy to get wrong: the journal has no schema-migration > mechanism (_ensure_schema is CREATE TABLE IF NOT EXISTS only) while all four SQL constants derive > from _COLUMN_LIST — so adding columns without an ALTER step bricks every existing journal file.

Subtask Index

IDDescriptionWPParallel
T001Red-first: incident reproduction (6 projects, 1 consented, other 5 with no consent record at all). RED on current code (SC-001)WP04
T002Cross-project pre-flight: refuse before any POST, name the projects, exit non-zero, no retry-count mutation. Identity resolved in-memory over the already-selected batch only (FR-004)WP01
T003Split drain_blocked_reason into transient gate reasons (re-evaluated at drain) and a terminal reason; exclude only the terminal one from _select_undelivered. Today the column collapses not saas_enabled OR not checkout_enabled into one token and also stamps missing_auth/missing_team (journal.py:338-352), and emitter.py:2246-2248 states drain-blocked events are re-evaluated each tick — so excluding all non-null values would permanently strand every pre-login capture. Covers ZERO incident rows — never treat as containmentWP06
T004UN-MARKED 2026-07-30 — was falsely recorded done. GateKind still holds exactly {SAAS_ENABLED, PRIVATE_TEAMSPACE, AUTH, ENDPOINT_CONFIGURED} (delivery/receivers.py:144-147); no consent member, no consent port on GateContext, no change to evaluate_gates. spec.md names this the first root cause. Residual scope after C-003 and WP06: a GateKind can only express machine-level facts (per IC-01), so what is still owed is "consent is unresolvable → abort the whole run", NOT per-project selection, which WP06 delivers. Decide explicitly: implement that machine-level gate, or retire FR-001 in the spec — do not re-mark done without one of those (FR-001)WP01
T005PARTIALLY DONE — un-marked 2026-07-30. The routing.py fail-close half shipped (de274f3f7b). The empty-selection short-circuit did not: grep -rn "nothing to deliver" src/ returns nothing, the misleading message still stands at sync/batch.py:1488, and the journal drain has no equivalent report. SC-003 requires five fail-closed tests, one per path; the empty-selection one does not exist (FR-003 done, FR-005 open)WP01
T006Two distinct rules — do not conflate them (corrected 2026-07-29). (a) Identity-less capture: FR-010 restated — the journal write requires identity, and an event whose identity cannot be resolved is stamped into a named non-deliverable state via the existing drain_blocked_reason vocabulary, not dropped; it stays durable and is counted under T013. (b) Non-consenting capture: per amended NFR-005 a non-consenting project's events must never reach the journal at all — capture-first durability applies only to consenting projects. Rule (b) is a deliberate reversal of the unconditional-write invariant documented in event_journal/journal.py, whose contract must be updated with it. Pinned by tests/sync/test_sync_consent_capture_gap_3031.py::test_non_consenting_project_event_never_reaches_the_journal. Beware the fake-green NFR-005 names: a bare if skip_journal: return event guard leaves capture unconditional at the real caller (_capture_to_journal, sync/emitter.py:2057)WP04
T007Precondition guard: require/run migrate_queues_to_journal, assert the legacy queue is empty, fail loudly rather than discard (pre-WP03 rows may have no journal copy)WP02
T008Delete the queue-backed daemon drain: background.py:395,455-461,589-592. Assert no code path constructs it (FR-012, SC-005). C-004's queue.remove_project_events retirement moved to WP08 (corrected 2026-07-29): its caller disable_checkout_sync needs a journal-side purge, and the journal had no project_uuid to purge by until WP04 — see tasks/WP08-purge.mdWP02
T009Terminal reject classification in delivery/receivers.py: DeliveryOutcome.TERMINAL_FAILED is reachable from exactly one predicate today (receivers.py:411-414, oversized-single-event). Map a stable server refusal reason there. delivery/ledger.py:98-101 already maps terminal_failed/failed_permanent, so no ledger work is needed. Folds #3005 (FR-014, SC-009)WP01
T010Additive journal migration: PRAGMA table_infoALTER TABLE ADD COLUMN, inside _ensure_schema, idempotent, before any derived-SQL use. Reuse the queue.py:1242-1248 precedent. Test opens a pre-migration DB file and reads it (C-001, C-002)WP04
T011Promote the identity resolver to sync/project_identity.py as one ordered constant chain incl. the fourth site payload.subject.project_uuid (emitter.py:2037); nil sentinel normalizes to NULL. Test asserts a single definition site (NFR-001)WP04
T012project_uuid/project_slug in ORDERED_COLUMNS + indexes; idempotent, lossless backfill using T011's chain (FR-006, FR-009, NFR-004, SC-007)WP04
T013Count unresolved-identity events for FR-011 (WP07 owns surfacing them)WP04
T014sync/consent.py: the one consent resolver, encoding the FR-013×FR-019 precedence chain exactly once (see spec.md "FR-013 × FR-019 reconciliation"): project-local .kittify/config.yaml (refusal outranks grant) → machine-global uuid-keyed index → env var (never a grant alone) → absent = deny. Index is written by enable_checkout_sync/disable_checkout_sync and is a cache, not a second source of truth: when a readable checkout disagrees, the file wins and the index is corrected (FR-013, FR-019)WP05
T015Absence of a consent record denies — expressed as resolver semantics in consent.py (WP05's file). The matching routing.py:87 default-allow flip is a WP01 DoD item, since WP01 owns routing.py; splitting it this way keeps both inside their lane and keeps the emit/body paths deny-by-default too (FR-002)WP05
T016Backfill path-keyed → uuid-keyed as a single batched write; unreadable paths retain the path entry with an unresolved marker the predicate ignores and WP07 renders; enable_checkout_sync fails loudly when no uuid resolvesWP05
T017Project-filtered journal read as an identity projection (event_id, created_at, project_uuid; no payload BLOB) with no LIMIT; payload hydration via read_by_id over the ledger-selected batch (FR-008, NFR-003)WP06
T018_select_undelivered consumes the filtered read; stored column is the sole authority for selection (FR-007, NFR-001)WP06
T019Liveness: 2,000 non-consented events older than 10 consented → one drain delivers all 10 (NFR-002, SC-002)WP06
T020Corrected target: max_events_per_batch/_should_probe_advertised_limits exist only in sync/batch.py — the drain WP02 retired (the module still exists — un-exported, not deleted). The journal drain's window is the local constant _EVENT_SYNC_DISPATCH_BATCH_LIMIT in _run_dispatch_batches (cli/commands/sync.py:807-820), halved and regrown on HTTP 413. Exercise the real window there; a fake advertising batch limits would test a corpse (NFR-007)WP07
T021Per-project breakdown in sync doctor/status/migrate, reconciled against the journal's retained count — not OfflineQueue().get_queue_stats(). Folds #3004. Renders unresolved-identity and unresolved-consent rows (FR-011, FR-015, SC-004)WP07Y
T022sync purge --project <slug-or-uuid> (dry-run default) + --all, over journal and ledger, via delivery/retention.py (FR-016, FR-017, NFR-006, SC-006)WP08Y
T023Document that SPEC_KITTY_ENABLE_SAAS_SYNC/SPEC_KITTY_SAAS_URL are machine-global; CI-checkable anchor (FR-018)WP09Y
T025Body-upload consent: prepare_body_uploads gates once at enqueue on is_sync_enabled_for_checkout(repo_root) (body_upload.py:150), which is default-allow on absence; _drain_body_queue then POSTs every task under only the machine-global is_saas_sync_enabled(). Resolve consent per task at drain time from the task's namespace project identity, not cwd. Bodies are full spec.md/plan.md/tasks/WP*.md text (body_upload.py:33-52)WP11
T026Add the body-upload queue to the purge differential — it shares the offline-queue DB file, so a purge reporting 100% today leaves queued bodies behindWP11
T027Gate local-commit frame emission and the connect-time flush on per-project consent, resolved from the frame's own identity rather than cwd (sync/local_commit.py, git/commit_helpers.py:1141-1157, flushed via sync/client.py:181-189). Found by the post-WP06 review; ships mission slugs = client engagement names with zero consent check (FR-002, NFR-001)WP12
T028Close _auto_start_enabled's two fail-open return True paths (sync/runtime.py:70,80) — inability to determine consent is not consent (FR-003's rule)WP12
T024Live drain against spec-kitty-dev at the incident's shape — ≥6 projects: 1 consented, ≥3 with no consent record, ≥1 explicit opt-out, ≥1 identity-less. Evidence artefact records before/after counts per project_slug, the drain's own delivered count, and the CLI commit SHA (SC-008)WP10

Ownership resolution (2026-07-28)

lanes.json mirrors owned_files, so a subtask that must write another lane's file is a hard stop at implement time. Resolved: WP01 owns receivers.py + routing.py (the delivery-decision surface), WP06 owns dispatcher.py + selection.py (the selection surface). T003 moved to WP06, T001/T006 to WP04, T005's batch.py message to WP02. Rationale in tasks/WP01-containment.md. Reversible if you would rather keep T003 in wave 1 — but note it protects none of the incident population.

Test-file ownership (added 2026-07-28)

The lane guard flagged that no WP declared its test files, so every WP would trip ACTIVE_WP_SCOPE_VIOLATION on its first red-first commit. Each WP must own the test modules it writes, alongside its source files. WP01 now owns tests/sync/test_routing.py and tests/delivery/test_receivers.py; the remaining WPs need the same treatment before they start.

Dependency graph

WP01 (containment + terminal reject) ─┐
WP02 (legacy drain removal)          ─┴─ no deps, ship first
                                        WP01 also unblocks saas#585 WP07

WP04 (identity enabler) ─┬─> WP05 (consent index) ──> WP06 (filtered read + gate) ──> WP10 (live)
                         ├─> WP07 (visibility, folds #3004)
                         └─> WP08 (purge)
WP05 ──> WP11 (body-upload consent — uncovered egress path)
WP09 (docs) — no deps

_capture_to_journal (emitter.py:2057) runs before every gate and is unconditional, so every queued event already has a journal copy.

reaches into SQL or private helpers, or the enabler reviews lock in implementation shape.

  • WP01, WP02, WP09 have no dependencies and may run in parallel. WP03 was deleted: its assigned files (delivery/ledger.py, delivery/interfaces.py) contained no work — the ledger already maps both terminal keys and the only file needing change is receivers.py, which WP01 owns. T009 moved to WP01.
  • WP02 is a deletion, not a gate — research decision 1 resolved to remove, because
  • WP05 depends on WP04 only for T011's shared resolver; it could start against that signature.
  • Each enabler asserts on its public seam (the filtered read API, the consent resolver) — no test

WP01 — Containment: refuse loudly, fail closed

Subtasks: T001–T006. Dependencies: none. execution_mode: code_change. Requirements: FR-001, FR-003, FR-004, FR-005, FR-010, SC-001, SC-003. Makes the leak loud with no schema change. Note honestly: this does not make delivery correct for a multi-project machine — WP06 does. FR-003 hardens the emit path, daemon batch and body uploads, not the drain that leaked (is_sync_enabled_for_checkout has zero callers under delivery/), so it must not be presented as containment.

WP02 — Remove the legacy queue drain

Subtasks: T007–T008. Dependencies: none. execution_mode: code_change. Requirements: FR-012, SC-005. Deletes the second live drain rather than teaching it consent.

WP04 — Journal identity enabler (migration + columns + backfill)

Subtasks: T010–T013. Dependencies: none. execution_mode: code_change. Requirements: FR-006, FR-009, FR-011 (count), NFR-004, NFR-005, SC-007, C-001, C-002, C-003. T010 must land before T012 — without the ALTER step, adding columns to ORDERED_COLUMNS breaks every existing journal file.

Subtasks: T014–T016. Dependencies: WP04. execution_mode: code_change. Requirements: FR-002, FR-013, FR-019. Consent is keyed by absolute path today (config.py:216,233) while events carry project_uuid; this supplies the missing join. SyncConfig setters are unlocked whole-file read-modify-writes, so a lost record is now a silent delivery denial — hence the single batched write.

WP06 — Filtered read and per-project selection

Subtasks: T017–T020. Dependencies: WP04, WP05. execution_mode: code_change. Requirements: FR-007, FR-008, NFR-001, NFR-002, NFR-003, NFR-007, SC-001, SC-002. The only per-project seam; FR-001 lives in WP01 because GateContext has no project dimension. NFR-001 is a subset invariant (delivered ⊆ consented and None ∉ delivered), never a cardinality check — identity-less events collapse to {None} and would satisfy cardinality == 1 while leaking.

WP07 — Operator visibility (folds #3004)

Subtasks: T021. Dependencies: WP04. execution_mode: code_change. Requirements: FR-011 (surfacing), FR-015, SC-004. Without #3004, the per-project report renders from the store that is empty after sync migrate — reproducing the incident's false-green.

WP08 — Purge

Subtasks: T022. Dependencies: WP04. execution_mode: code_change. Requirements: FR-016, FR-017, NFR-006, SC-006. Spans two stores: journal rows and delivery-ledger history. Research decision 2 (retain or remove ledger history) must be answered inside this WP and recorded.

Subtasks: T025–T026. Dependencies: WP05. execution_mode: code_change. Requirements: FR-002, FR-016. Found by the post-tasks squad; no earlier artefact mentioned it. sync now calls drain_body_uploads_only() (cli/commands/sync.py:2368) — the same command the operator ran twice — and that drain ships full document bodies with no per-project consent. Same breach class, live path, previously unowned.

WP09 — Documentation

Subtasks: T023. Dependencies: none. execution_mode: code_change. Requirements: FR-018.

WP12 — Local-commit frames (third uncovered egress path)

Subtasks: T027–T028. Dependencies: WP05. execution_mode: code_change. Requirements: FR-002, NFR-001. Found by the post-WP06 adversarial review (2026-07-30); no earlier artefact mentions it. Same breach class as WP11 — a live path shipping project identity with no consent check — folded in rather than deferred. See tasks/WP12-local-commit-consent.md for the full trace.

WP10 — Live verification

Subtasks: T024. Dependencies: WP06. execution_mode: code_change. Requirements: SC-008. Owns the criterion no other WP claimed. Never production — see docs/production-safety-guardrails.md.