# ADR-0161 — HITL Async Queue (W-009, L11) **Status:** Proposed **Date:** 2026-05-26 **Author:** Shay **Parent scope:** [L11 — HITL Async Queue Scope](./L11-hitl-async-queue-scope.md) **Closes:** W-009 (substrate-liveness-ratchet) --- ## Context L11 named eight sub-questions the eventual ADR must answer. ADR-0057 already pinned proposal eligibility, replay-equivalence, the `pending → accepted | rejected | withdrawn` state machine, and the append-only proposal log. ADR-0151 made `proposal_id` deterministic over (candidate_id, chain). ADR-0152 closed the engine-authored proposal loop end-to-end. ADR-0155 added a CI contemplation runner that proposes via PR review. `.github/workflows/ratify-proposal.yml` exposes `accept_proposal` over `workflow_dispatch` so the operator can ratify from a phone. What is still missing is a queue *shape* — a named, replayable view over those existing append-only sources, plus the minimum new machinery needed for backpressure, mobile-feasible inspection, and full state-transition (`reject` and `withdraw`) from the same surface that ratifies. This ADR answers L11's eight sub-questions by selecting from the menu L11 listed, in every case toward the narrowest commitment that still names a testable invariant. The principle behind every call below is the same: the queue is **not a new persistence substrate**. It is a deterministic projection of the proposal log and the contemplation-run artifacts the project already commits to. Anything that cannot be derived from those sources is out of scope. --- ## Decision summary | L11 sub-question | Decision | |---|---| | 1 — Persistent representation | Derived view over `teaching/proposals/proposals.jsonl` ∪ `contemplation/runs/*.json`. Queue identity = `proposal_id`. No new persistence file. | | 2 — Operator interaction surface | Three surfaces, ranked: GitHub PR (mobile-primary, inspect-only), `workflow_dispatch` (mobile-primary, accept/reject/withdraw), local CLI (authoritative). PR-merge is admission, not ratification. | | 3 — Engine behavior while pending | Engine keeps serving turns. Pending proposals are observable but never active truth. No proposal-on-proposal dependencies. | | 4 — Bounds and backpressure | Pending-count cap of **256**. Dedup by deterministic `proposal_id`. No wall-clock expiry. When full, contemplation runner emits a typed `queue_full` report instead of a new proposal. | | 5 — Trust boundary + ratification log | Only the repo owner ratifies. Every transition appends a record carrying `ratifier_kind`, `actor`, `commit_sha`, and (for workflow path) `workflow_run_id`. CI may stage; CI may not ratify. | --- ## Decision detail ### 1. Persistent representation The queue is a **derived view** over two append-only sources that already exist on disk and in git history: - `teaching/proposals/proposals.jsonl` — the canonical proposal log defined by ADR-0057. Carries `proposal`, `replay`, `transition`, and `accepted_corpus_append` events. - `contemplation/runs/*.json` — CI-emitted contemplation reports defined by ADR-0155. Each report carries one proposal candidate and its replay evidence. No third file is introduced. **Queue identifier.** A queue item is identified by `proposal_id` (deterministic SHA-256 over `(candidate_id, proposed_chain)` per ADR-0151). Queue items and proposals are 1:1. A contemplation report that has not yet been ingested by `propose_from_candidate` is a *pre-queue artifact*; it becomes a queue item only when its `proposal_id` appears in `proposals.jsonl`. **Required derived fields.** For each queue item the projection exposes: | Field | Source | |---|---| | `proposal_id` | proposals.jsonl `proposal` event | | `source_kind` | proposals.jsonl `proposal.source.kind` | | `source_id` | proposals.jsonl `proposal.source.source_id` | | `proposed_chain` | proposals.jsonl `proposal.proposed_chain` | | `replay_evidence` | proposals.jsonl `replay` event (most recent) | | `state` | last `transition.to` for this `proposal_id`, or `"pending"` if absent | | `review_history` | full ordered list of `transition` events | | `contemplation_report_path` | `contemplation/runs/*.json` whose `proposal_id` matches, if any | The projection is a pure function of the two source files. Replay of the proposal log reconstructs the queue byte-identical. **State enumeration.** The state set is **exactly** ADR-0057's existing alphabet — `pending | accepted | rejected | withdrawn`. No new states. "Stale", "superseded", "expired", and "duplicate" are **not** queue states; they are conditions detectable at submission or review time (see §4) and recorded as a `rejected` transition with a typed reason in the `note` field. **No deletion.** Append-only. All historical events remain visible under `core teaching proposals --include-history`. --- ### 2. Operator interaction surface Three operator surfaces exist. Each has a distinct role. None is optional; together they satisfy L11's "mobile-feasible" constraint while preserving the local CLI as the audit-grade authority. #### Surface A — GitHub PR (mobile-primary, inspect-only) ADR-0155's contemplation workflow opens a PR whose diff is a single `contemplation/runs/.json`. The PR body now also **embeds the queue summary**: pending count, this proposal's `proposal_id`, replay-equivalence outcome, and the chain in human-readable form. The operator inspects from the GitHub mobile app. **Merging the PR admits the proposal to the queue. It does not ratify.** This separation matters: merging is "the artifact is now part of the audit trail"; ratifying is "this artifact mutates the corpus". #### Surface B — workflow_dispatch (mobile-primary, transition-capable) `.github/workflows/ratify-proposal.yml` currently exposes `accept_proposal` only. This ADR extends it to a single workflow parameterized by `action ∈ {accept, reject, withdraw}`. Invocation from the GitHub mobile app provides a phone-feasible path to **every** state transition, not only acceptance. Every workflow run records, into the same `proposals.jsonl` event it appends: ```json { "event": "transition", "proposal_id": "...", "to": "accepted|rejected|withdrawn", "note": "", "ratifier_kind": "workflow_dispatch", "actor": "", "commit_sha": "", "workflow_run_id": "", "review_date": "YYYY-MM-DD" } ``` #### Surface C — local CLI (authoritative) `core teaching review --accept|--reject|--withdraw` remains the audit-grade authority. Records use `ratifier_kind: "cli"` and carry the operator's OS username and the local `HEAD` SHA at the moment of ratification. Identical preconditions hold across surfaces B and C: 1. `proposal_id` exists in the log. 2. Current state is `pending`. 3. `replay_evidence.replay_equivalent` is `true`. 4. Acting actor matches the repo-owner allow-list (see §5). A workflow that violates any precondition fails closed with a non-zero exit and emits no transition event. The proposal stays `pending`. #### New read-only surfaces Two new CLI commands expose the queue projection: - `core teaching hitl-queue list [--state pending|accepted|rejected|withdrawn|all]` — prints `proposal_id`, source kind, age (in proposals, not wall-clock — see §4), replay status, and current state. - `core teaching hitl-queue show ` — prints the full derived record including `review_history` and the contemplation-report reference if one exists. Both are pure projections; neither mutates state. --- ### 3. Engine behavior while pending The engine keeps running. ADR-0146/0150/0152 already commit to this; this ADR makes it precise for queue semantics. - **Live turns.** Pending proposals do not participate in grounding, recall, proposition-graph admissibility, or eval scoring. They are invisible to `chat.runtime.ChatRuntime.chat`. - **Continued production.** The engine may keep producing new proposals while earlier ones are pending, subject to §4 backpressure. - **No proposal-on-proposal dependencies.** A proposal whose `proposed_chain` semantically depends on another *pending* proposal's ratified state is rejected at submission with reason `dependent_on_pending`. Dependencies between proposals create ratification ordering constraints that quietly couple HITL choices; forbidding them keeps every ratification independent. If a chain genuinely depends on another, the dependent proposal is re-proposed *after* the dependency lands. - **Replay across ratification.** Work produced before a ratification replays under the pre-ratification corpus. Work produced after replays under the post-ratification corpus. The proposal log preserves the order so this remains deterministic. --- ### 4. Bounds and backpressure The queue has a hard cap on **pending** items. Accepted, rejected, and withdrawn items do not count toward the cap. #### Pending cap **Cap: 256 pending proposals.** Rationale: contemplation can run nightly; replay equivalence is the expensive part of producing a proposal, but human review is the expensive part of clearing one. 256 ≈ a year of nightly proposals if the operator clears one per day on average. Any higher and the operator's mental model of "what is pending" stops fitting in human attention; any lower and a short CI burst could refuse work the operator would have wanted to see. This is the only magic number in the ADR; it is operator-tunable via repo variable `CORE_HITL_PENDING_CAP` and defaults to 256 when the variable is absent. The default change is itself a reviewed change (this ADR); the variable is for *raising* the cap, not lowering it below default. #### Dedup A proposal whose `proposal_id` already exists in the log is rejected at submission as `duplicate`. Because `proposal_id` is deterministic over content (ADR-0151), this is a content-based dedup, not a timestamp dedup. Replay of the same candidate produces the same `proposal_id` and is silently coalesced. #### Expiry **There is no wall-clock expiry.** Pending proposals stay pending until the operator transitions them. Wall-clock expiry would couple queue state to runner time and break deterministic replay. Operator withdrawal is the only path out of `pending` other than accept/reject. #### Full-queue behavior When `pending_count >= cap`, the contemplation runner emits a typed `queue_full` report instead of a new proposal: ```json { "report_kind": "queue_full", "pending_count": , "cap": , "candidates_skipped": [{"candidate_id": "...", "reason": "queue_full"}, ...] } ``` This is written to `contemplation/runs/.json` and opened as a PR exactly like a normal contemplation report. The PR body makes the full state visible to the operator on their phone. No proposal is silently dropped: the `candidate_id` of every skipped candidate is recorded so a future run (after the operator clears queue space) can re-emit it. #### Age (in proposals, not wall-clock) Queue listing exposes an `age_proposals` integer — the number of *subsequent* proposals appended to the log after this one entered `pending`. This is a replayable, deterministic notion of staleness. Wall-clock time is recorded in events but never load-bearing for queue ordering or backpressure. --- ### 5. Trust boundary and ratification log #### Who may ratify Only the repo owner. Enforcement: - **Surface B (workflow_dispatch).** The workflow's `if:` guard rejects any `github.actor` not in the repo's `CORE_RATIFIERS` variable (comma-separated GitHub logins). When the variable is unset, the default ratifier set is `${{ github.repository_owner }}` alone. The job fails closed with a clear log message if the actor is unauthorized. - **Surface C (local CLI).** No additional check beyond the existing local filesystem authority. The CLI cannot be exercised remotely; possession of a local working copy is the trust boundary. CI workflows (contemplation runner, lane-SHA verifier, smoke gate, etc.) **cannot ratify**. They have no path to `accept_proposal`. They may stage artifacts and open PRs, nothing more. #### Ratification record Every transition appends a single JSONL event to `proposals.jsonl`: ```json { "event": "transition", "proposal_id": "", "to": "accepted|rejected|withdrawn", "note": "", "ratifier_kind": "cli|workflow_dispatch", "actor": "", "commit_sha": "", "workflow_run_id": "", "review_date": "YYYY-MM-DD" } ``` `actor`, `commit_sha`, and `workflow_run_id` are recorded for audit but are **not load-bearing for state reconstruction**. Replay of the log requires only `proposal_id` and `to`. This keeps the replay substrate small while making audit forensics complete. #### Replay invariants Given `proposals.jsonl` and `contemplation/runs/*.json`, the queue state at any historical point is reconstructible byte-identical by folding events in order. The new fields (`ratifier_kind`, `actor`, `commit_sha`, `workflow_run_id`) appear in audit projections but not in the trace hash inputs. Existing trace_hash invariants from ADR-0153 are preserved. --- ## Out of scope (deferred) This ADR scopes only the HITL async queue. It does not commit to: - a Workbench API or UI surface (ADR-0160 W-026..W-031); - engine-identity continuity (`project-engine-identity-candidate`, shelved — un-shelve only if cross-reboot ratifier identity becomes load-bearing); - recognizer-storage durability (separate ADR); - pack mutation queue (packs remain reviewed-ratify-only outside this queue); - safety / ethics ratification semantics; - a queue prioritization heuristic (queue is FIFO by log order); - automatic dependency-chasing across pending proposals; - Slack / email / push-notification delivery; - a daemon or background worker; - a database-backed queue. The queue presented here is the **minimum** structure that satisfies L11. Each deferred item can become its own ADR without disturbing this one. --- ## Implementation plan Five small PRs, each a self-contained step, none of which mutate existing recorded queue history: ### Step 1 — `core teaching hitl-queue` read commands - New module `teaching/queue.py` exposing a pure `derive_queue(log)` function that returns the projection in §1. - New CLI subcommand `core teaching hitl-queue list|show` wired in `core/cli.py`. - Tests: pure derivation over fixture proposals.jsonl; states match ADR-0057's alphabet; replay-equivalence in derivation. - **No mutation paths.** ### Step 2 — Backpressure (pending-count cap) - `propose_from_candidate` in `teaching/proposals.py` consults pending count via `teaching.queue.derive_queue` and writes a `queue_full` report at `contemplation/runs/_queue_full.json` instead of a new proposal when the cap is reached, returning `RefusedAtCapacity`. - `contemplation/runs/.json` schema extended with `report_kind ∈ {"learning_arc", "queue_full"}` (default `"learning_arc"` for back-compat). - Repo variable `CORE_HITL_PENDING_CAP` honored; default 256. - Tests: synthetic full-queue triggers `queue_full` report; candidate_id of every skipped item is recorded; replay still byte-identical. ### Step 3 — Submission-time invariants **Landed in `feat(ADR-0161.3): submission-time invariants — duplicate + dependent_on_pending auto-reject`.** Two pre-gate checks added to `propose_from_candidate` in `teaching/proposals.py`, firing in this order (after the Step 2 cap check): 1. **Duplicate check** — computes the deterministic `proposal_id` and scans `derive_queue()` for any existing item with the same id. If found, returns `RefusedAsDuplicate(proposal_id, existing_state)`. Covers all states (pending, accepted, rejected, withdrawn). 2. **Dependent_on_pending check** — walks all pending queue items; if any pending item's `proposed_chain.subject` or `.object` lemma matches the candidate's subject or object (case-insensitive exact-match), returns `RefusedAsDependent(candidate_id, dependent_on, overlapping_lemmas)`. Conservative: over-reject rather than admit-with-hidden-dependency. Neither refusal writes to `proposals.jsonl`. The append-only invariant holds. New frozen dataclasses exported from `teaching/proposals.py`: ```python @dataclass(frozen=True, slots=True) class RefusedAsDuplicate: proposal_id: str existing_state: str reason: str = "duplicate" @dataclass(frozen=True, slots=True) class RefusedAsDependent: candidate_id: str dependent_on: tuple[str, ...] overlapping_lemmas: tuple[str, ...] reason: str = "dependent_on_pending" ``` CLI surfaces both in `cmd_teaching_propose` and `cmd_teaching_propose_from_exemplars` with exit code 1. Tests in `tests/test_hitl_queue_submission_invariants.py`. ### Step 4 — Extend ratification workflow to reject/withdraw - `.github/workflows/ratify-proposal.yml` gains an `action` input (`accept | reject | withdraw`) and dispatches to the corresponding CLI subcommand. - Workflow guard enforces `actor ∈ CORE_RATIFIERS` (defaults to repo owner if variable is unset). - Transition record includes `ratifier_kind: "workflow_dispatch"`, `actor`, `commit_sha`, `workflow_run_id`, `review_date`. - Mirror updates to `core teaching review` so CLI records `ratifier_kind: "cli"` symmetrically. - Tests: precondition failures emit no transition event; unauthorized actor fails closed. ### Step 5 — Embed queue summary in contemplation PR body - The contemplation workflow's PR body now includes the queue summary (pending count, cap, this proposal_id, replay outcome, human-readable chain). - Operator inspecting from mobile sees full queue context without opening the JSON. - No corpus or ratification effect. Each PR ships with its own ADR-compatibility statement, lane tests, and read-only invariant assertion (no mutation outside `proposals.jsonl` and `contemplation/runs/`). --- ## Acceptance criteria This ADR is ratifiable when: 1. The queue projection is a pure function of the two source files, with a test that proves the projection is byte-identical across a randomized event-order replay (the source files are append-only, so order is fixed in practice — the test confirms the projection does not depend on hidden state). 2. The pending-count cap fires deterministically in a synthetic test that pre-populates `proposals.jsonl` with 256 pending entries; the 257th submission produces a `queue_full` report and no proposal. 3. Every transition event records `ratifier_kind`, `actor`, and `commit_sha`, asserted by tests against both CLI and workflow surfaces. 4. The workflow's actor guard fails closed in a CI test that fakes a non-allowlisted `github.actor`. 5. `core teaching hitl-queue list` and `... show` succeed against the current `teaching/proposals/proposals.jsonl` on `main` without mutating any file (snapshot assertion). --- ## Consequences ### Positive - Closes W-009, the last open substrate-liveness ratchet item. - The queue exists without introducing a new persistence file. - Mobile ratification path covers all three transitions, not just accept. - Backpressure is bounded and deterministic — no silent drops, no unbounded growth. - Audit forensics gain `actor` / `commit_sha` / `workflow_run_id` without changing replay semantics or trace_hash inputs. ### Negative - The pending cap is a single magic number; raising it remotely requires the operator to change a repo variable from a phone. This is an accepted trade for keeping the queue projection a pure function of two files. - Forbidding proposal-on-proposal dependencies will occasionally force a re-emission of a chain after its dependency lands. This is intentional: dependent ratification ordering is the wrong failure mode to bake in. - Symmetry between CLI and workflow surfaces enlarges the surface area for `accept_proposal`'s preconditions; tests must enforce identical precondition behavior across both paths. ### Risks - `ratifier_kind` and `actor` are recorded but not used for authorization beyond the workflow's `if:` guard. Local CLI trust remains "possession of the working copy". If the threat model later admits multi-operator scenarios, a follow-up ADR must add a CLI-side identity check; this ADR explicitly does not. - `dependent_on_pending` detection uses lemma-substring heuristics. False positives reject genuinely independent chains. False negatives admit dependent chains that ratification ordering will surface later. The conservative choice is to err toward false positives (over-reject), since rejected proposals can be re-emitted; the alternative silently couples ratifications. --- ## Cross-references - [L11 — HITL Async Queue Scope](./L11-hitl-async-queue-scope.md) - [L10 — Runtime Model Scope](./L10-runtime-model-scope.md) - [ADR-0057 — Teaching-Chain Proposal + Review + Replay-Equivalence Gate](./ADR-0057-teaching-chain-proposal-review.md) - [ADR-0151 — Load-time auto-proposal pipeline](./ADR-0151-auto-proposal-pipeline.md) - [ADR-0152 — Learning-arc demo (proof corridor)](./ADR-0152-learning-arc-demo.md) - [ADR-0153 — TurnEvent.trace_hash back-stamp](./ADR-0153-turn-event-trace-hash-backstamp.md) - [ADR-0155 — CI contemplation runner](./ADR-0155-ci-contemplation-runner.md) - [ADR-0156 — Atomic engine-state checkpoint](./ADR-0156-atomic-engine-state-checkpoint.md) - [ADR-0157 — Revision-mismatch warning](./ADR-0157-revision-mismatch-warning.md) - [ADR-0158 — Reboot-event audit trail](./ADR-0158-reboot-event-audit.md) - [`.github/workflows/ratify-proposal.yml`](../../.github/workflows/ratify-proposal.yml) - [CLAUDE.md](../../CLAUDE.md) — deterministic replay, exact recall, proposal-only learning, no hidden state. ### Memory cross-references - [[thesis-decoding-not-generating]] — the queue must teach the engine *to find* better-ratified evidence, not just store another found thing. Backpressure-as-`queue_full`-report rather than silent-drop honors this. - [[feedback-adr-cross-reference-discipline]] — every decision above selects from existing ADRs and workflows; no parallel mechanism is introduced. - [[feedback-address-critiques-dont-waive]] — L11's eight sub-questions are answered in order, not deferred. - [[project-engine-identity-candidate]] — remains shelved; the `actor` field in transition records covers the audit need without un-shelving engine-identity work.