Skip to content

Close the draft consumer's six review findings, and park a sweep that could only reach what it meant to spare (#765) - #768

Merged
mkreyman merged 1 commit into
masterfrom
fix/draft-consumer-followups
Aug 27, 2026
Merged

Close the draft consumer's six review findings, and park a sweep that could only reach what it meant to spare (#765)#768
mkreyman merged 1 commit into
masterfrom
fix/draft-consumer-followups

Conversation

@mkreyman

Copy link
Copy Markdown
Owner

The six confirmed findings from #767's enhanced review. They sat outside that diff's write scope, but every one is a consequence of it, so they are fixed rather than filed.

An unassessed draft could publish as :novel

The consumer refuses to publish when assessment fails. One layer down, ProposalGate could not distinguish "no neighbours found" from "the neighbour search was unavailable" — a shed or degraded vector read returns an empty list, which scores as maximally novel. The guard above was defeated by the layer below it.

Every assessment now carries comparison: :complete | :unavailable, and the consumer treats :unavailable as NOT ASSESSED, exactly as it already treats :unknown.

The create path is deliberately unchanged and still falls open — a write must not 500 because a novelty read shed. The two callers want opposite things from the same failure, which is why the fix distinguishes rather than changing the default.

The sweep worker could only reach the class it documented it must spare

The finding worth reading. DraftDuplicateSweepWorker archives drafts whose nearest published neighbour clears a high threshold, and it sweeps only drafts that have an embedding. Embeddings are enqueued at status: :published only — so every row it could ever archive was published once and then retracted, which is precisely the class its own "what it must NOT sweep" section says to protect. It spared only the half consolidation wrote, leaving a human retraction reachable.

It is now parked, not rewritten. Nothing was swapped in for archive because a draft is already the bottom of the reversible lifecycle — {:published, :draft} and {:draft, :published} are the pair, :archived is the exit from it — so there is no reversible retirement to move to.

Parking changes the authority, not the action: a human may take a terminal act, and an operator unparking this is a human asking for exactly that. The design below it is preserved intact for a revive, including the measurement behind the threshold.

The rest

  • A deliberately staged draft now has a veto, not a 48-hour floor. staged_draft_at is a column, stamped programmatically, absent from every cast list — the lesson previous_title and lifecycle_entered_at each paid for.
  • A covering index for the candidate scan, which was reading the tenant's articles on the 3-connection admin pool that every authenticated request also checks out of.
  • The pre-marker retraction residual, verified against the code and documented at the guard site as the deliberate bounded cost of not stranding every pre-marker draft permanently.
  • A CHANGELOG entry — warranted here where the last two changes correctly omitted one, and it names its categories: two migrations, and OBAN_UNPARK_CRONS as the lever that revives the parked worker.

Verification

7,970 tests green via the pre-commit hook (credo --strict, dialyzer included). Both migrations run clean on dev and test.

Per the review skill's own rule, an out-of-scope list is fixed on a follow-up branch and not re-reviewed — a full review already produced these findings, and re-reviewing its own output is the unbounded chain the depth counter exists to stop.

… could only reach what it meant to spare (#765)

Six confirmed findings from #767's enhanced review. They sat outside that diff's
write scope but every one is a consequence of it, so they are fixed here.

AN UNASSESSED DRAFT COULD PUBLISH AS NOVEL. The consumer refuses to publish when
assessment fails, but one layer down ProposalGate could not tell "no neighbours
found" from "the neighbour search was unavailable" - a shed or degraded vector read
returns an empty list, which scores as maximally novel. The guard above was
therefore defeated by the layer below it. Every assessment now carries
comparison: :complete or :unavailable, and the consumer treats :unavailable as NOT
ASSESSED, exactly as it already treats :unknown. The CREATE path is deliberately
unchanged and still falls open: a write must not 500 because a novelty read shed.
The two callers want opposite things from the same failure, which is why the fix is
to distinguish rather than to change the default.

THE SWEEP WORKER COULD ONLY REACH THE CLASS IT DOCUMENTED IT MUST SPARE. This is the
finding worth reading. DraftDuplicateSweepWorker archives drafts whose nearest
published neighbour clears a high threshold, and it sweeps only drafts that HAVE an
embedding. Embeddings are enqueued at status published only, so every row it could
ever archive was published once and then retracted - precisely the class its own
"what it must NOT sweep" section says to protect. It spared only the half
consolidation wrote, leaving a human retraction reachable.

It is now PARKED rather than rewritten. Nothing was swapped in for archive because a
draft is already the bottom of the reversible lifecycle - published to draft and back
is the pair, archived is the exit from it - so there is no reversible retirement to
move to. Parking changes the AUTHORITY instead of the action: a human may take a
terminal act, and an operator unparking it is a human asking for exactly that. The
design below it is preserved intact for a revive, including the measurement behind
the threshold.

A DELIBERATELY STAGED DRAFT NOW HAS A VETO, not a 48-hour floor. staged_draft_at is a
column stamped programmatically and absent from every cast list, so an ordinary PATCH
cannot erase it - the same lesson previous_title and lifecycle_entered_at each paid
for.

Plus a covering index for the candidate scan, which was reading the tenant's articles
on the three-connection admin pool that every authenticated request also checks out
of; and the pre-marker retraction residual, verified against the code and documented
at the guard site as the deliberate bounded cost of not stranding every pre-marker
draft permanently.

The CHANGELOG entry that the previous two changes correctly omitted is warranted
here, and it names its categories: two migrations, and OBAN_UNPARK_CRONS as the lever
that revives the parked worker.
@mkreyman
mkreyman merged commit 758d1fd into master Aug 27, 2026
16 checks passed
@mkreyman
mkreyman deleted the fix/draft-consumer-followups branch August 27, 2026 18:13
mkreyman added a commit that referenced this pull request Aug 27, 2026
)

* Alarm when the nightly consumers go quiet with work waiting (#765)

The last item of #765, and the one that guards the failure that started it.
KnowledgeLintWorker died at 600s for six consecutive nights and it read as quiet
success, because nothing in the system watched for ABSENCE. Every class now has a
consumer - generic_title retitles, duplicate_capture unpublishes, drafts publish and
link - so a night where all of them apply zero is indistinguishable from a healthy
clean corpus. That is the hole this closes.

IT DOES NOT LIVE IN THE PASS IT WATCHES, which is the whole design. #761 killed the
job before it wrote its audit event at all, so a detector inside that pass would have
been just as dead, and a streak counted over events freezes exactly when the system
breaks. It runs from the hourly IngestionHealthWorker instead - different queue,
different cadence - reading only the durable rows the pass leaves behind.
KnowledgeLintWorker's timeout and reserve arithmetic are untouched; the check costs
that job nothing.

QUIET AND BLIND ARE DIFFERENT and the distinction is the point. A clean corpus with
an empty draft queue and every gate open stays silent forever. A candidate needs zero
applications across the window AND one of three things: work actually waiting; a hard
blind signal (a failed gate, the -1 step-failed sentinel, or a wall clock that cut in
before the first application, which counts alone because a step starved every night is
a defect whether or not work happened to be queued); or a paused drain with its work
corroborated independently. That corroboration is what keeps the alarm honest - a
paused gate short-circuits before the candidate query and reports zero offered whether
the queue is full or empty, so without it a keyless tenant with nothing to do would
alarm forever, which is how a real switch gets muted.

STATE IS DERIVED FROM THE AUDIT EVENTS the pass already writes, not from a new
counter, because a counter is one more thing that can be silently wrong. The history
window is derived from the two thresholds rather than picked beside them and clamped
to the audit retention. Recovery closes on what this run could actually evaluate,
never on absence from the candidate list - so an anomaly cannot be auto-closed by its
own evidence ageing out.

N is 7 runs. A quiet night neither starts nor extends a streak, so 7 means 7 nights on
which work was waiting or the step could not act - at ~11 drafts a night that is ~77
held drafts, which is unambiguous. Three knobs, all resolving DB row to app config to
default, so an operator can retune without a deploy.

ON THE INDEX MIGRATION AND ITS LOCK. audit_log is a RANGE-partitioned parent, where
CREATE INDEX CONCURRENTLY is unsupported, so this takes a SHARE lock per partition and
blocks audit writes while it builds - and audit writes are on the path of every API
call. I measured production before taking it rather than reasoning about it: the
current partition is 12 MB over 8,443 rows, July is 10 MB over 8,034, future
partitions are 48 kB, and only June is large at 128 MB, which ages out of the 90-day
retention shortly. An index build at that size is seconds. The alternative is a
PERMANENT hourly sequential scan on the three-connection admin pool that every
authenticated request also checks out of, which is the same cost #768 just removed
from the draft candidate query. A brief lock beats a standing scan.

NO DRAINER WAS BUILT for the 905 proposals sitting in pending, and that half of the
item is deliberately not implemented. review_status is vestigial by the schema's own
moduledoc - nothing in lib branches on it, the nightly upsert resets it on every
re-derivation, and the controller's own OpenAPI text says so. Those rows are historical
scan records, not a queue, and a drainer for them would be motion without signal. The
half of item 6 that IS real - no state has a human as its only exit - is what the
switch encodes: a paused or keyless drain no longer sits silently forever, it surfaces
after N nights, and resolve or archive on the anomalies endpoint are its exits.

Seventeen mutations, each killed by a named test, covering both directions: a clean
corpus never alarms, and work-waiting-with-zero-applied always does.

* fix(review): round-1 - stop the consumer switch alarming on healthy installs, and let it see the outages it was built for

Fourteen review findings on the nightly consumer dead-man's-switch, all in the
same detector.

False alarms it could never take back:
- consumer_runs had no active-tenant join, the only detector in the module
  without one. Suspending a tenant stops the pass by design, so 72h later the
  pass half paged the operator about work they had turned off, and recovery
  (which IS status-gated) could never close it.
- Deliberate refusals counted as work waiting. A duplicate group the
  corroboration gate withheld, a title the provider abstained on, a curated
  article the retitle correctly skips: the scan re-proposes each every night, so
  a healthy corpus alarmed forever and the only remedy muted the consumer.
  Those counters are now subtracted from the work signal instead of added.
- Recovery closed on the stall condition merely lapsing. A dead drain stops
  being a candidate the moment a sweep archives the drafts behind it, which
  stamped resolved into the append-only audit log about a consumer that had
  disposed of nothing. It now needs positive evidence: recovered_keys.

Outages it could not see:
- The read was bounded by the derived class window (14 days at the defaults),
  so a pass dead for longer than that had no rows to judge and produced no
  alarm at all. The read now runs to the audit retention; the derived window
  bounds only the per-class streak. The docstring claiming 90 days was wrong by
  6x and is replaced.
- consumer_stall_runs above the retention could never fill a window, silently
  switching off every per-class check while the run still reported zero
  candidates. It is now clamped to the retention, with a warning.
- The scan_limit cut on a stable tenant_id sort blinded the same tail of
  tenants on every run. The sort key now rotates hourly.
- The retention ceiling was a hardcoded 90 rather than the configurable key
  AuditPartitionWorker actually drops partitions by.

Blast radius:
- A globally dead nightly cron stales every tenant in one run, so the pass half
  now emits ONE system-scope operator alert per run like sweep_stalled does,
  instead of a page per tenant. The per-tenant anomaly rows and webhooks are
  unchanged.
- window_seconds in the operator alert reported the observed staleness rather
  than the detection window, unlike every sibling clause.
- The draft corroboration read was one query per tenant on the 3-connection
  admin pool, and an install-wide pause put every tenant on that path. It is
  one bounded read now, taken only when some run reports a paused drain.

Migrations:
- The audit_log partial index built on the partitioned parent, blocking every
  audit write - and so every mutating request - for the whole build. It is now
  created ON ONLY, with each partition built CONCURRENTLY and attached.
- The anomaly-type rollback DELETEd recorded stalls that the audit chain still
  references. It retags and resolves them instead.

Also: the three consumer knobs were documented under the ingestion_health
keyword list while tunable/3 reads them as top-level loopctl keys, so an
operator override was silently ignored; and source_type_seen? guarded on four
hand-copied sentinel strings that already exist in consumer_classes.

* fix(review): round-2 - stop the refusal subtraction and the recovery gate from blinding the switch

A deliberate refusal is still subtracted from the work signal, but SATURATION is
now the opposite reading: refusing every offer while applying none is exactly what
a dead extraction provider or a dead vectorisation input looks like, and the
counters that carry the benign case carry that one too. Both classes read it as
hard-blind, so a 100 percent refusal streak can no longer subtract itself to
silence, while a refusal beside a real disposition stays quiet.

Recovery gets its second route back. Requiring a disposition left a healthy but
idle consumer's anomaly open forever, and the operator's only remedy leaves
last_event_at NULL, which suppresses every future stall of that key. A full window
with nothing offered, no blind gate and no paused gate now closes and re-arms; a
still-paused drain does not.

Also: clamp the stall streak to HALF the audit retention, since the derived window
is double it and above half a single missed night leaves every tenant permanently
short of a window; bound the draft corroboration read to the tenants that actually
reported a paused gate, instead of an unqualified DISTINCT over articles on the
three-connection admin pool; make the partition-index probe ask for a VALID
attached index rather than a matching NAME, and drop and rebuild an interrupted
build's invalid orphan; archive the rows the anomaly-type rollback retags, so a
rolled-back install does not list a capture_silence anomaly for a consumer; and
give the pass system alert the same injectable insert seam its sweep twin has,
with the dropped-enqueue test that seam exists for.

* Publish the consumer_stalled type in the anomalies contract, not only in the code (#765)

The fifth anomaly type shipped with parse_anomaly_type and known_anomaly_type
accepting it while the OpenAPI spec still listed four. A caller reading the
published contract would conclude consumer_stalled is not a filterable value, and
this repo's rule is explicit that a new API constraint belongs in the endpoint's
operation spec and not just the controller guard.

Both sites now name it: the filter parameter, and the summary that explains what
each type means. The summary entry says what the type IS - a dead-man's switch that
detects ABSENCE rather than failure - because that is the part a reader cannot infer
from the name, and because it is the property that makes silence from this detector
meaningful rather than merely unremarkable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant