Skip to content

Give the draft pile an automatic consumer, so captured knowledge reaches the corpus (#765) - #767

Merged
mkreyman merged 3 commits into
masterfrom
feat/draft-consumer
Aug 27, 2026
Merged

Give the draft pile an automatic consumer, so captured knowledge reaches the corpus (#765)#767
mkreyman merged 3 commits into
masterfrom
feat/draft-consumer

Conversation

@mkreyman

Copy link
Copy Markdown
Owner

The last item of loopctl#765, and the blocker for mkreyman/claude-config#221.

The leak

113 articles sat in status: :draft, growing ~11/night, with zero automatic consumers. Under the governing constraint (owner decision, KB 837daaa0) that is not a safety gate — it is total loss. An agent never sees a draft, so a held article is indistinguishable from one that was never captured. Holding is the harm; publishing is recoverable.

Two of the spec's premises did not survive measurement

1. There is no proposal_novelty stamp to read. #765 says to dispose of drafts using the near-neighbours "already stamped in metadata.proposal_novelty". Zero of the 113 carry that key — their metadata is empty. They are direct orchestrator deposits (article.created) that never went through propose_article/3's novelty branch. A consumer written to the spec would read a nil and do nothing on all 113. This one assesses, through the existing ProposalGate rather than a second novelty implementation.

2. Archive is not reversible. #765 says a near-duplicate twin may be archived because "archive is soft-delete, therefore reversible, therefore allowed". Article's @valid_transitions carries no {:archived, _} entry and there is no unarchive function — :archived is terminal, reachable back only by a user-role PATCH. That is why the consolidation pass retracts with unpublish and never archive (#608), and CLAUDE.md says so outright (#605/#606). An unattended writer may not take a one-way door.

The disposition: publish-and-link

verdict action
:novel publish
:low_novelty / :duplicate publish AND link to the nearest published neighbour with a relates_to edge carrying the cosine score
:unknown, or a verdict outside the contract leave held, count it

The near-duplicate case hands redundancy to the existing potential_conflict promoter + judge — reversible end to end, and demonstrably working (226/226 pairs judged on 2026-08-27). Nothing is archived, no body is discarded, and unpublish_article/3 is the exact inverse of the publish_article/3 this takes, both writing audit events.

A failed assessment publishes nothing. A provider outage, a shed heavy read, or an off-contract verdict leaves the draft held. Defaulting an unrecognised verdict to :novel would be the same unassessed publish the :unknown branch already refuses — mutation-verified in both directions.

Bounds — third time in this worker, same discipline

Only 1 of the 113 drafts had an embedding (publishing enqueues one; being created as a draft does not), so the per-item cost is an embedding call plus a vector read. Therefore a wall clock, and the budget is carved out of the existing job timeout rather than added beside it:

  • timeout/1 does not move (Lifeline's 30-min rescue_after is the real ceiling)
  • @job_reserve_ms becomes @prelude_reserve_ms + @retitle_reserve_ms + @draft_reserve_ms; the judge's ceiling falls 18 → 16 min, which is exactly what this step may spend
  • the drain cap (25) sits above the producer rate (~11/night), so the 113 backlog clears inside a week rather than holding at its current level; it resolves from a SystemConfig row first, so 0 pauses the drain fleet-wide without a deploy
  • the night reports what it was offered and whether the clock truncated it, in the log line and the audit event

Verification

7,942 tests green via the pre-commit hook (credo --strict, dialyzer included). Guards mutation-verified independently of the implementer's report:

mutation test that goes red
:unknown publishes instead of holding "a failed assessment leaves the draft alone and COUNTS it"
off-contract verdict defaults to :novel "a verdict outside the behaviour's vocabulary is NOT treated as novel"

No CHANGELOG entry: by that file's own rule (five categories, "ONLY") a behavioural change routes to git log --first-parent.

…hes the corpus (#765)

113 articles sat in draft, growing about 11 a night, with zero automatic
consumers. Under the governing constraint - there is no human approver and never
will be - that is not a safety gate, it is total loss: an agent never sees a
draft, so a held article is indistinguishable from one that was never captured.
This is the last item of #765 and the blocker for claude-config#221.

TWO PREMISES IN THE SPEC DID NOT SURVIVE MEASUREMENT, and the design follows the
measurement.

First, the spec says to dispose of drafts using the near-neighbours already
stamped in metadata.proposal_novelty. ZERO of the 113 carry that key - their
metadata is empty. They are direct orchestrator deposits that never went through
propose_article's novelty branch, so a consumer written to the spec would read a
nil and do nothing on every one of them. This consumer ASSESSES, through the
existing ProposalGate rather than a second novelty implementation.

Second, the spec says a near-duplicate may be merged or linked and the twin then
archived, because archive is a soft delete and therefore reversible. Archive is
TERMINAL: Article's valid transitions carry no entry out of archived and there is
no unarchive function, which is why the consolidation pass retracts with unpublish
and never archive. An unattended writer may not take a one-way door.

So the disposition is publish-and-link. Novel drafts publish. Near-duplicates
publish AND carry a relates_to edge to their nearest published neighbour with the
cosine score, handing the redundancy to the potential_conflict promoter and judge
- a pipeline that is reversible end to end and judged 226 of 226 pairs on
2026-08-27. Nothing is archived, no body is discarded, and unpublish_article is
the exact inverse of the publish this takes.

AN ASSESSMENT THAT FAILS PUBLISHES NOTHING. A provider outage, a shed heavy read
or a verdict outside the behaviour's vocabulary leaves the draft held and counts
it. Defaulting an unrecognised verdict to novel would be the same unassessed
publish the unknown branch already refuses.

BOUNDS, in the worker that died of getting this wrong twice. The per-item cost is
an embedding call plus a vector read - only 1 of the 113 drafts had an embedding,
because publishing enqueues one and being created as a draft does not - so the
bound is a wall clock and the budget is carved OUT of the existing job timeout
rather than added beside it. timeout/1 does not move. The judge's ceiling falls
from 18 to 16 minutes, which is what this step may spend, and the arithmetic stays
in the one clamp that already does it.

The drain cap sits above the producer rate on purpose: about 11 drafts arrive a
night and the cap is 25, so the 113 backlog clears inside a week instead of
holding at whatever level it reached. It resolves from a SystemConfig row first,
so 0 pauses the drain fleet-wide without a deploy.

Every night reports what it was OFFERED and whether the clock truncated it, in
the log line and the audit event, because a truncated night and a night with
nothing to do are otherwise the same number.

Mutation-verified: making a gate-unavailable verdict publish reddens the
failed-assessment test, and defaulting an off-contract verdict to novel reddens
its own.
…at were held on purpose

draft_scope/1 treated every :draft row as an abandoned capture. Three classes are
staged deliberately and are now excluded:

- a RETRACTION. The audit guard was narrowed to worker:consolidation, so
  unpublish_article/3 and bulk_unpublish/3 - the role: user lever - were undone
  unattended within 24 hours. The NOT EXISTS now spares an article.unpublished row
  written by ANY actor.
- a MERGE. A draft carrying metadata.merged_from is never offered. The KB-content
  carve-out that lets an agent-role key record a merge verdict rests on that
  synthesised draft never being auto-published.
- a FRESH HOLD. A draft is offered only once it is 48h old, so the advertised
  draft:true and ingestion publish:false staging opt-ins stay observable.

Also in this round:

- the candidate window reserves a fifth of the cap for the NEWEST drafts. With no
  per-draft attempt counter, a purely oldest-first window stalls at zero forever
  once a cap's worth of permanently unconsumable drafts reaches the head.
- a keyless tenant is stopped BEFORE the candidate query, on has_embedding_key?/1
  rather than the Anthropic has_api_key?/1 the warning was reading. Offering wrote
  one llm.blocked_no_api_key audit row per draft per night, forever, for a
  known-zero outcome.
- the annotation edge is scoped to the auto-linker's own neighbour scope (same
  project or global, never private/owner), so this step adds no edge class the
  graph has never held and cannot flag a cross-project or private pair into
  curated suppression.
- an off-contract verdict is named instead of tagged as the literal string
  unknown, which is what ExitTag.tag/1 returns for a map.
- the sizing prose now states the NET drain: 30 offered against ~11 arriving
  clears 113 in about six nights, not four. Corrected in all three sites.
- the moduledoc and the call-site ordering comment no longer claim the promoter
  and judge handle the redundancy reversibly. The judge's dismiss is terminal and
  retires neither article, consolidation's duplicate_capture reaches only
  colliding titles and idempotency keys, and a flagged pair suppresses both its
  articles from curated answers until it is judged.
… the clock can reach

Six root causes from the round-2 review, all in the draft consumer.

Retraction guard was only as durable as the audit partitions. The column is
stamped by consolidation alone and only since 20260818055453, the audit_log is
DROPped past audit_retention_days, and a user+ PATCH to status draft writes
article.updated and no unpublished row at all - so a human retraction came back
unattended after ~90 days, or immediately by that PATCH. A third record closes
both: a draft carrying an embedding was PUBLISHED once (embeddings are enqueued
at published only, ingestion only under publish true) and that row outlives
every partition. It also splits the queue cleanly - the weekly sweep takes the
embedded drafts, this step the unembedded captures.

Merge guard hung on metadata.merged_from, which an agent-role PATCH casts and
whole-map-replaces - the previous_title and lifecycle_entered_at lesson, third
instance. Excluded now by the executor's own conflict_resolutions row, which no
caller can cast.

The reserved NEWEST slice was appended after the backlog, and the wall clock
truncates the tail - so a slow unconsumable head discarded exactly the slice
added to keep the drain positive. The two slices are interleaved now.

linkable? judged the head neighbour only, so an out-of-scope rank 1 threw away
an in-scope rank 2 the auto-linker links anyway; the whole scored list is
walked. A near-duplicate published with no annotation is counted as
published_link_failed rather than hiding inside published, and the warning names
the real cause instead of blaming a malformed assessment.

The keyless-tenant warning fired nightly for tenants with no drafts, asserting
drafts were held when none were; it speaks only when something is actually held.

Comments corrected rather than qualified: the visibility half of linkable? is
STRICTER than the auto-linker (which passes no visibility scope at all), not a
mirror of it, and the moduledoc says where a merge draft's exit now is.
@mkreyman
mkreyman merged commit a60fb6f into master Aug 27, 2026
16 checks passed
@mkreyman
mkreyman deleted the feat/draft-consumer branch August 27, 2026 17:24
mkreyman added a commit that referenced this pull request Aug 27, 2026
… could only reach what it meant to spare (#765) (#768)

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.
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