Implements the 4-phase documentation reorganization master plan. - Consolidation: Merged brief/, handoff/, planning/, and decisions/ into briefs/, handoffs/, plans/, and adr/ respectively (101 ADRs relocated) - Root Cleanup: Relocated HANDOFF-gpt55-*.md and key top-level docs (runtime_contracts.md, etc.) to canonical folders. Added superseded alerts. - Indices & Navigation: Created docs/README.md navigation document, docs/sessions/README.md index, docs/adr/README.md index - Note: Also includes prior commit adding ADR-0200+ corpus hygiene governance (ADR-0225, dependency map, backfilled cross-references)
22 KiB
ADR-0161 — HITL Async Queue (W-009, L11)
Status: Proposed Date: 2026-05-26 Author: Shay Parent scope: L11 — HITL Async Queue Scope 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. Carriesproposal,replay,transition, andaccepted_corpus_appendevents.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:
{
"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:
proposal_idexists in the log.- Current state is
pending. replay_evidence.replay_equivalentistrue.- 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]— printsproposal_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 includingreview_historyand 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_chainsemantically depends on another pending proposal's ratified state is rejected at submission with reasondependent_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:
{
"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 anygithub.actornot in the repo'sCORE_RATIFIERSvariable (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:
{
"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.pyexposing a purederive_queue(log)function that returns the projection in §1. - New CLI subcommand
core teaching hitl-queue list|showwired incore/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_candidateinteaching/proposals.pyconsults pending count viateaching.queue.derive_queueand writes aqueue_fullreport atcontemplation/runs/<timestamp>_queue_full.jsoninstead of a new proposal when the cap is reached, returningRefusedAtCapacity.contemplation/runs/<timestamp>.jsonschema extended withreport_kind ∈ {"learning_arc", "queue_full"}(default"learning_arc"for back-compat).- Repo variable
CORE_HITL_PENDING_CAPhonored; default 256. - Tests: synthetic full-queue triggers
queue_fullreport; 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):
-
Duplicate check — computes the deterministic
proposal_idand scansderive_queue()for any existing item with the same id. If found, returnsRefusedAsDuplicate(proposal_id, existing_state). Covers all states (pending, accepted, rejected, withdrawn). -
Dependent_on_pending check — walks all pending queue items; if any pending item's
proposed_chain.subjector.objectlemma matches the candidate's subject or object (case-insensitive exact-match), returnsRefusedAsDependent(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:
@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.ymlgains anactioninput (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 reviewso CLI recordsratifier_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:
- 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).
- The pending-count cap fires deterministically in a synthetic test
that pre-populates
proposals.jsonlwith 256 pending entries; the 257th submission produces aqueue_fullreport and no proposal. - Every transition event records
ratifier_kind,actor, andcommit_sha, asserted by tests against both CLI and workflow surfaces. - The workflow's actor guard fails closed in a CI test that fakes a
non-allowlisted
github.actor. core teaching hitl-queue listand... showsucceed against the currentteaching/proposals/proposals.jsonlonmainwithout 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_idwithout 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_kindandactorare recorded but not used for authorization beyond the workflow'sif: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_pendingdetection 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
- L10 — Runtime Model Scope
- ADR-0057 — Teaching-Chain Proposal + Review + Replay-Equivalence Gate
- ADR-0151 — Load-time auto-proposal pipeline
- ADR-0152 — Learning-arc demo (proof corridor)
- ADR-0153 — TurnEvent.trace_hash back-stamp
- ADR-0155 — CI contemplation runner
- ADR-0156 — Atomic engine-state checkpoint
- ADR-0157 — Revision-mismatch warning
- ADR-0158 — Reboot-event audit trail
.github/workflows/ratify-proposal.yml- 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
actorfield in transition records covers the audit need without un-shelving engine-identity work.