core/docs/hitl-backpressure.md
Shay 72fac59029
feat(ADR-0161.3): submission-time invariants — duplicate + dependent_on_pending auto-reject (#313)
Adds two pre-gate checks to propose_from_candidate that fire after the
Step 2 capacity check and before the replay gate.  No log entry is
written on either refusal — the append-only invariant holds.

Check order at function entry (ADR-0161 §3):
  1. Capacity (Step 2)          → RefusedAtCapacity
  2. Duplicate                  → RefusedAsDuplicate
  3. Dependent_on_pending       → RefusedAsDependent
  4. Replay gate                → auto-reject on regression

New frozen dataclasses:

  @dataclass(frozen=True, slots=True)
  class RefusedAsDuplicate:
      proposal_id: str
      existing_state: str        # covers all states: pending/accepted/rejected/withdrawn
      reason: str = "duplicate"

  @dataclass(frozen=True, slots=True)
  class RefusedAsDependent:
      candidate_id: str
      dependent_on: tuple[str, ...]       # pending proposal_ids that block
      overlapping_lemmas: tuple[str, ...] # normalised lemmas that triggered
      reason: str = "dependent_on_pending"

Lemma-overlap rule: case-insensitive exact-match on strip().lower().
Conservative — over-reject rather than admit-with-hidden-dependency.
False positives are recoverable (re-emit after blocker is ratified);
false negatives silently couple ratification choices.

CLI surfaces both outcomes in cmd_teaching_propose and
cmd_teaching_propose_from_exemplars (exit code 1).

Step 2 backpressure tests updated: made pre-populated candidates use
unique objects to avoid triggering the new dependency check, and
updated idempotency assertions to reflect the new RefusedAsDuplicate
return for re-submitted content.

Co-references: ADR-0161 §3, Step 1 PR #296, Step 2 PR #311,
ADR-0057, ADR-0151.
2026-05-26 16:46:25 -07:00

3.9 KiB

HITL review queue backpressure

To prevent reviewed proposal queues from growing beyond human attention limits, the queue enforces a pending count cap (ADR-0161 §4).

Configuration

  • Default Cap: 256 pending proposals.
  • Environment Override: To temporarily raise the cap, set the CORE_HITL_PENDING_CAP environment variable:
    export CORE_HITL_PENDING_CAP=512
    

Accepted, rejected, and withdrawn proposals do not count toward the cap.

Queue Full Reports

When the pending count is at or above the cap, propose_from_candidate will refuse to create a new proposal and instead emit a queue_full report to the contemplation runs directory: contemplation/runs/<ISO-8601-UTC>_queue_full.json

Example queue_full report shape:

{
  "report_kind": "queue_full",
  "emitted_at_revision": "9a738a16...",
  "pending_count": 256,
  "cap": 256,
  "candidates_skipped": [
    {
      "candidate_id": "8a9b2c3d...",
      "shape_category": "factual",
      "reason": "queue_full"
    }
  ]
}

The CLI exits with code 1 when capacity is refused, alerting CI runners and operators of the backpressure event.

Operator Clearance Loop

When the queue is full and proposals are skipped:

  1. Review Pending Proposals: Inspect the current queue using:
    core teaching hitl-queue list --state pending
    
  2. Clear Space: Ratify or withdraw pending proposals:
    # Accept a proposal
    core teaching review <proposal_id> --accept --review-date 2026-05-26
    
    # Or withdraw a proposal
    core teaching review <proposal_id> --withdraw
    
  3. Re-run Propose: Once pending count falls below the cap, re-run the propose command. Skipped candidates will land successfully as fresh proposals with the same deterministic proposal_id.

Submission invariants

Before the replay gate runs, propose_from_candidate applies two additional content-based checks (ADR-0161 §3, Step 3). They fire in this order, after the capacity check:

Duplicate

A candidate whose deterministic proposal_id (SHA-256 over candidate_id + proposed_chain, per ADR-0151) already exists in the log is refused with RefusedAsDuplicate. This covers all existing states — pending, accepted, rejected, and withdrawn — because content-identical proposals carry the same id regardless of their history.

CLI output:

duplicate: proposal_id=<id> existing_state=<state>

No log entry is written. The refusal is operator-facing only.

Dependent on pending

A candidate whose proposed_chain.subject or .object lemma (case-insensitive exact-match) overlaps with any pending proposal's subject or object is refused with RefusedAsDependent. This prevents ratification-ordering constraints from being silently baked into the queue.

CLI output:

dependent_on_pending: dependent_on=[<proposal_ids>]
overlapping_lemmas=[<lemmas>]

No log entry is written. The operator should re-emit the candidate after the dependency proposal is ratified.

Conservatism trade-off: the heuristic uses exact-match on the normalised lemma string (strip().lower()). A genuinely independent chain that happens to share a common lemma word (e.g. "truth") will be refused. This is intentional: false positives are recoverable (re-emit after the blocking proposal clears); false negatives silently couple ratification choices. If over-rejection becomes frequent, the operator should ratify or withdraw the blocking pending proposals rather than loosening the heuristic.

Check order summary

capacity check (Step 2)      ← queue_full report, no log entry
  ↓ (if under cap)
duplicate check (Step 3)     ← RefusedAsDuplicate, no log entry
  ↓ (if not duplicate)
dependent_on_pending (Step 3) ← RefusedAsDependent, no log entry
  ↓ (if no dependency)
replay gate                  ← runs; regression auto-rejects via transition
  ↓ (if replay-equivalent)
pending proposal created     ← created event appended to proposals.jsonl