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.
116 lines
3.9 KiB
Markdown
116 lines
3.9 KiB
Markdown
# 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:
|
|
```bash
|
|
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:
|
|
```json
|
|
{
|
|
"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:
|
|
```bash
|
|
core teaching hitl-queue list --state pending
|
|
```
|
|
2. **Clear Space**: Ratify or withdraw pending proposals:
|
|
```bash
|
|
# 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
|
|
```
|