# 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/<timestamp>.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": "<operator_note>",
  "ratifier_kind": "workflow_dispatch",
  "actor": "<github.actor>",
  "commit_sha": "<github.sha>",
  "workflow_run_id": "<github.run_id>",
  "review_date": "YYYY-MM-DD"
}
```

#### Surface C — local CLI (authoritative)

`core teaching review <proposal_id> --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 <proposal_id>` — 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": <N>,
  "cap": <C>,
  "candidates_skipped": [{"candidate_id": "...", "reason": "queue_full"}, ...]
}
```

This is written to `contemplation/runs/<timestamp>.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": "<sha256>",
  "to": "accepted|rejected|withdrawn",
  "note": "<free-text>",
  "ratifier_kind": "cli|workflow_dispatch",
  "actor": "<github.actor | os.user>",
  "commit_sha": "<HEAD at transition>",
  "workflow_run_id": "<github.run_id or null>",
  "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/<timestamp>_queue_full.json` instead of a new proposal
  when the cap is reached, returning `RefusedAtCapacity`.
- `contemplation/runs/<timestamp>.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.
