P13 source classification inventory

Authority: ADR-039 P13 and consolidation review section 4. Delivery: P13 foundation and family convergence.

Source inventories and owner determinations

p13-source-inventory.json answers “which source sites declare or erase a retention class?” It counts declarations. It cannot say how many tables the estate has, who owns them, or whether a class is earned.

p13-table-inventory.json answers “which tables does the estate actually have, who owns each one, and what is its class by evidence?” It counts tables. It starts from the migration files that create tables, so the denominator is the real table set rather than a label grep.

Neither is estate compliance. Both are source-only.

The table inventory also records runtime DDL definitions in runtimeWriters. A dynamic name stays a source template, with its compiling project and declared source owner; it is not counted as a new physical table. A compiled template’s classification is read from its source-site disposition: classified-runtime-source gives {class, source}, an inactive-declaration with basis session-temporary or runtime-inactive-source gives {class: "inactive", basis, source}, and anything else stays pending under the residue row. A template records no CREATE site when a labelled comment in the same file declares the same subject, because the comment carries the declaration. Such a template reads its disposition from that comment’s site, but only when exactly one comment of that subject carries an entry. Temporary SQL, SQLite, non-host plugin source and orphan source remain visible as separate facts. A build graph establishes source reachability, not whether a method ran on a particular estate. Physical database ownership and pending classifications still require their family acceptance under P13-2.

Two gates, different claims

Run from the repository root, with Python 3.9 or newer:

python tools/scripts/validate/check-p13-retention.py --repo-root . --self-test
python tools/scripts/validate/check-p13-retention.py --repo-root .
python tools/scripts/validate/check-p13-retention.py --repo-root . --strict

The first command proves the detector’s positive and negative controls. The normal command checks inventory/growth integrity. Exit zero means the current observed source sites are accounted for; it does not mean the tables comply with P13. The strict command returns nonzero while individually recorded repairs remain. --json preserves this distinction in inventoryValid versus strictCompliant; both results have authority: source-only.

No command connects to a database, calls a service, runs a migration, creates a runtime object or writes files. The architecture pack invokes the same detector and verifies this distinction.

Reviewed owner determinations

p13-owner-determinations.json records the reviewed owner/instance decisions that the migration graph alone cannot establish. The 2026-09-21 source review covers 110 migration subjects and 48 runtime creation sites. The strict owner gate reports zero unresolved groups. Earlier 225/63 counts included predecessor writers that later sprints retired.

python tools/scripts/validate/check-p13-retention.py --repo-root . --owners --json
python tools/scripts/validate/check-p13-retention.py --repo-root . --owners --strict --json

Ordinary --owners returns zero when the reviewed artifact has exact current coverage and valid proof, including its explicitly unresolved groups. Strict owner mode returns nonzero until every group is resolved. The 2026-09-21 result has inventoryValid=true, ownerResolutionComplete=true and strictCompliant=true. Owner mode reports the reviewed resolution separately: it does not change --tables’ ownerResolved, retention classes, physical database counts or live-state claims.

The audit verifies creator projects and their dispositions, selected chains, executable host projects, connection/parameter binding shape and every full-source fingerprint/literal proof. It recomputes the fixed startup-selector census (53 files, 64 selectors after EST-7’s consolidation) and all ten inactive symbol/reference censuses over production C#, plugin manifests and the packaging script. New callers, changed registrations, narrowed census scopes, missing coverage and stale source proofs fail integrity. A selector in a helper still does not prove every transitive host invokes it.

The checker also pins the complete reviewed artifact with OWNER_REVIEW_SHA256, using its existing sorted-key ASCII tooling JSON digest. This binds owner choices and activation explanations as well as their source references. It is not a product signature or a physical ownership attestation. On drift, inspect the changed creator, selector, caller or binding first; do not refresh the anchor to make a red audit pass. Re-review the affected owner determinations and their source evidence, then update the artifact and its canonical digest together under explicit review. Run the detector self-tests, ordinary/strict owner modes and adjacent architecture specs, retaining unresolved groups until their implementation evidence is complete. Formatting JSON does not change the canonical digest; an owner determination change does.

Provenance and unresolved work

p13-source-inventory.json records individual source observations and their dispositions. legacyProvenance is content-pinned by the detector, not refreshed by a normal run. Each site binds its path, source-root owner lead, lineage, location, declared subject/class and source/declaration fingerprints. A source-root name is not proof of a deployed database owner.

Explanatory SQL comments are not class declarations. Quoted SQL metadata is preserved, including comment-marker characters inside its string, so an invalid token cannot be truncated into a valid class.

BIN-8’s symbols.catalog_manifest has a reviewed source-of-truth disposition. The hub stores operator-selected membership keyed by (source_key, pack_id, debug_id, content_hash); the member set cannot be reconstructed from the symbol payload alone. Its five hash-bound proofs identify the Vulnerabilities database binding, membership writer, startup migration registration, foreign key disposal boundary and authoritative composite key. Source disable and pack deactivation keep membership rows. Deleting the owning catalog entry cascades to membership; deleting a referenced manifest is restricted. No TTL or retention-pruner claim is attached to this operator state.

Migration 009 introduces this one table; migration 010 adds a lookup index and the symbols.available_manifest view over existing tables, with no independent stored row set. These source-site proofs do not reclassify the existing symbol tables or change the per-table detector’s narrower evidence rules. Regenerate its derived census only after the source commit. The generated database-variable field uses the owner’s actual STELLAOPS_POSTGRES_VULN_CONNECTION binding from VulnerabilitiesPersistenceExtensions.ConnectionEnvironmentVariable; deriving VULNERABILITIES from the module directory would name a variable this host does not read.

Operational-state references cover the terminal predicate/window, size budget, Doctor check and active-row safety test. Append-audit references cover partitioning, the retention window and custody boundary. Derived-rebuildable requires its rebuild path; cache-bounded requires its size/TTL bound; source-of-truth requires authoritative identity evidence. Nothing here grants deletion or overrides legal holds, cryptographic floors, idempotency references or tenant protections.

--draft-inventory <full-source-sha> prints a review draft. It changes no file or accepted anchor. Do not use it to admit new unknown labels or to mark repairs complete. Updating historical provenance requires explicit hunk/source review; forward repairs must retain applied SQL bytes.

Repaired bounded-state families (P13-3)

The export-run engine’s two job families are operational state: rows exist to carry a job through its lifecycle and are reclaimed afterwards. Both had retention paths that deleted rows the job was still using.

FamilyTableRepaired paths
Risk-bundle jobsexport_center.risk_bundle_jobsCount cap (durable and in-memory) now ranks TERMINAL rows only. The age window already did.
Exception-report jobsexport_center.exception_report_jobsAge window AND count cap (durable and in-memory) now match TERMINAL rows only. Neither did.

Terminal means Completed, Failed or Cancelled for risk-bundle jobs and completed or failed for exception-report jobs. Queued, leased and running rows survive both caps, and active rows are not counted toward the row cap: the cap governs finished work, and in-flight work is bounded by the concurrency and queue limits instead. The count cap was the sharper defect of the two, because PruneExpiredJobsAsync passes a null age cutoff when no retention period is configured — leaving the unfiltered cap as the only branch that ran.

Budgets are declared under Export:Retention (see OfflineKit architecture, data model); an unset key keeps the code default rather than being written back as a choice. The doctor.offlinekit.retention.bounded-state-budgets check reports each family’s declared budget as configured or absent, fails on a budget that disables both prune branches, and reports the terminal backlog against the declared cap.

Tables given a retention path (P13-5, P13-6, P13-8)

P13-3 repaired prune paths that existed and were wrong. These three tables had no prune path at all: each carried an operational-state claim, or a retention rule in its own comment, that no code could carry out. Each was closed the same way — a terminal-only predicate, a caller on the owning family’s own cycle, a declared budget whose absence reports as absent, a Doctor check, and a forward migration re-issuing the class.

TableWhat was missingTerminal predicateCallerBudgetDoctor check
export_center.audit_bundle_jobsNo prune member existed in either storeTerminal job states, both branchesWorker idle tick and submissionExport:Retention:AuditBundle:*doctor.offlinekit.retention.bounded-state-budgets
export_center.mirror_run_cursorsMigration 007 argued no prune was neededsource_id <> ALL(@declared)Each successful export runMirrorCursorRetentionOptions.DefaultMaxCursorsPerProfiledoctor.offlinekit.retention.mirror-cursor-budget
evidence.timestamp_remediation_rate_countersMigration 005 stated a retention rule and shipped no mechanismwindow_start_at < @cutoffremediation-rate-counter-prune, dailyEvidence:TimestampAssurance:Remediation:RateCounterRetentiondoctor.evidence.retention.rate-counter-budget

Two of the three needed an argument about what “terminal” means before a predicate could be written, and in both cases the obvious answer was unsafe:

In all three the earlier declaration is dispositioned superseded-by-forward-evidence rather than re-proved. A forward migration that corrects an earlier claim supersedes it; it does not make the earlier claim retroactively true.

Re-verify:

pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -Class "*RetentionTerminalRowTests"
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.OfflineKit.WebService.Tests/StellaOps.OfflineKit.WebService.Tests.csproj -Method "*RetentionBudgets*"

Operational-state tables whose obligations were proved (RFC-17)

The census read these tables as operational-state by shape, because it could see a terminal-state DELETE, but none was registered in p13-table-proofs.json. Registering one required building the parts that were missing, not citing what already existed.

TableWhat was wrong or missingTerminal predicateWindow and budgetDoctor check
export_center.export_incidentsThe count cap ranked every incident, so it could delete an open one. Nothing bound the budget from configuration, and nothing measured it. The 001 label said source-of-truth; migration 012 corrects it.status IN (4, 5) on both branches (Resolved, FalsePositive)Export:Retention:Incidents:{RetentionPeriod,MaxStoredIncidents}, 1 day and 200 terminal incidentsdoctor.offlinekit.retention.bounded-state-budgets, incidents family
attestor.rekor_submission_queuePurgeSubmittedAsync had no caller, and the dead-letter purge ran only when the worker dequeued work. No current path enqueues here, so a drained queue was never purged. There was no budget and no Doctor check. Evidence migration 010 replaces the operational label.status IN ('submitted', 'dead_letter')attestor:rekorQueue SubmittedRetentionDays and DeadLetterRetentionDays (30 each), MaxTerminalRowsPerTenant (10,000)doctor.evidence.retention.rekor-queue-backlog

The child export_center.export_incident_updates is append-audit and cascades from a reclaimed incident, as its own header in migration 006 states. Every transition is also published as a timeline event. No table references attestor.rekor_submission_queue, and a submitted row’s durable record is the transparency log it was submitted to.

Re-verify:

pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/OfflineKit/__Tests/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -Class "*ExportIncidentRetentionTests" -BuildProjectReferences
pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/StellaOps.Evidence.WebService.Tests.csproj -Class "*RekorSubmissionQueueRetentionTests" -BuildProjectReferences

Eventing ledgers reclaimed by default (RFC-14, owner ruling Q-42)

Every host that embeds the eventing chain through AddEventingReliability now runs the outbox and inbox pruners, their 15-minute cadence and the doctor.eventing.retention.overdue-backlog check. Before the 2026-09-23 ruling both pruners were opt-in and disabled by default, and the measured result was 11.3M unpruned inbox rows in Policy’s database and 5.1M in Platform’s. Eventing migration 005 restates the headers: eventing.outbox and eventing.inbox are operational-state, and eventing.leases is source-of-truth.

TableTerminal predicateWindow and budgetNot reclaimed
eventing.inboxat or below the row’s own consumer checkpoint, same epoch (or a superseded epoch)7-day library default, a floor a host may raise; 1,000 rows x 50 batches per passrows whose consumer holds no checkpoint
eventing.outboxa stream the host declares, at or below every consumer’s checkpoint (local checkpoints and active remote registrations) at the current epochsame default and budget, per declared stream, plus a 5-minute time budget per stream per pass; a host may declare a larger budget and a shorter delay while a backlog lastsa stream the host does not declare; it is measured every pass

There is no switch that disables either pruner. An undeclared outbox stream is not reclaimed because nothing in its producer’s database can prove its rows consumed: a catalog change feed is re-read from sequence 0 by a rebuilt replica, which the live survey of 2026-09-23 confirmed for Platform’s environment_state feed. The first sweep on a host that never pruned is bounded by the per-pass budget, so a multi-million-row inbox drains over hours rather than in one statement. The delete matches the oldest rows first by primary key, and the freed space is reused after autovacuum; the table file does not shrink on disk without VACUUM FULL.

Each delete statement reads at most one batch of a primary-key range and carries a cursor to the next statement. The ranges are: an outbox stream’s current epoch up to the consumed floor; for the inbox, each consumer/stream pair’s checkpoint epoch up to its checkpoint; and the superseded epochs below and above that epoch, which are walked as two separate ranges so the walk never reads the current epoch. The age bound (created_at, processed_at) filters the rows of a batch and is never the bound of a scan. A range’s walk stops at its end, at the first batch that reaches a row inside the window, or when the pass budget is spent. A statement that finds a range empty is not charged to the budget. Rows are appended in key order, so a row whose age is out of key order is reclaimed by a later pass, never early. The inbox delete still joins the live checkpoint, so the checkpoint as it stands when the row is locked is the safety predicate. The doctor.eventing.retention.overdue-backlog count reads at most countCap rows of each range.

A failure on one outbox stream, or on one inbox consumer/stream pair, is logged with the stream or pair named, and the pass continues with the next one. The pass then ends in failure (OutboxRetentionPassException / InboxRetentionPassException, carrying the completed reports), and the cadence service logs which streams or pairs failed. Rows a failed stream deleted before its failure stay deleted. Cancellation is not a stream failure and still ends the pass.

Before 2026-10-09 each statement re-ran a filtered LIMIT until a batch came back short. The statement that came back short read every row of its range younger than the cutoff: 24.4M rows of the hub’s vuln.facts stream, whose consumer is caught up, so the floor bounded nothing. The 30 s Npgsql command timeout cancelled it on every pass, and the exception ended the pass before vuln.consensus and vuln.corpus were reached. The superseded pass (stream_epoch <> epoch) and the inbox statement (a sequential scan of the whole table per batch) had the same shape.

Outbox prune budget and the backlog cadence (2026-10-12)

Each batch statement starts its primary-key scan at the cursor. Until 2026-10-12 the cursor was the row comparison (stream_epoch, seq) > (cursor) beside stream_epoch = epoch (or beside a second lower bound on stream_epoch). PostgreSQL checked it on every index entry but started the scan at the first key of the epoch, so each statement re-read the range from its start to the cursor. On the vulnerabilities hub, 1,000 rows 10.3M entries into vuln.facts cost 460.9 ms and 67,116 buffers, against 0.5 ms and 84 buffers with the cursor as the start key; the inbox statement had the same shape. The current epoch is now bounded by seq > cursor, the earlier superseded range keeps the row comparison beside an upper bound only, and the later superseded range is bounded by the row comparison alone.

The outbox pass is bounded three ways, all Eventing:OutboxRetention options:

OptionLibrary defaultBounds
BatchSize1,000rows, row locks and WAL per statement; each statement is its own transaction
MaxBatchesPerPass50statements per stream per pass
PassTimeBudget5 minuteswall-clock time per stream per pass; the sweep starts no statement once it is spent
BacklogIntervalunset (Interval)the delay before the next pass when a pass ended on its budget on any stream

A pass that stops on either budget reports BatchLimitReached, and the next pass continues where it stopped. With BacklogInterval set, one host deletes at most BatchSize x MaxBatchesPerPass rows per stream per (pass time + BacklogInterval) while a backlog lasts, and returns to Interval once a pass drains every stream. Each replica that runs the cadence adds its own passes; concurrent passes split the rows through SKIP LOCKED. Autovacuum work is set by the rows deleted, not by the rate: each autovacuum cycle scans every index of the table once, so a faster rate runs the same cycles closer together.

The vulnerabilities hub declares 5,000 rows x 40 statements (200,000 rows per stream per pass), a 60 s time budget and a 5-minute BacklogInterval: at most about 2.4M rows per stream per hour for one host, against 200,000 an hour (4.8M a day) before. Its measured peak was about 1.5M vuln.facts rows in one hour and 12.4M in one day (both 2026-10-09); a 50,000-row pass took 0.6 to 3.3 s, so the 15-minute cadence, not the statements, limited the old rate.

Scale proof (performance lane, a 4M-row young backlog under a 1 s command timeout):

STELLAOPS_PERFORMANCE_TESTS=1 pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/__Libraries/__Tests/StellaOps.Eventing.Reliability.Tests/StellaOps.Eventing.Reliability.Tests.csproj -Method "*Prune_CompletesUnderAShortCommandTimeout*" -BuildProjectReferences

Re-verify:

pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/__Libraries/__Tests/StellaOps.Eventing.Reliability.Tests/StellaOps.Eventing.Reliability.Tests.csproj -Class "*EventingRetentionDefaultOnTests","*InboxRetentionPrunerTests","*OutboxRetentionPrunerTests","*OutboxRetentionBudgetTests" -BuildProjectReferences

The effective per-table inventory (P13-1)

python tools/scripts/validate/check-p13-retention.py --repo-root . --tables
python tools/scripts/validate/check-p13-retention.py --repo-root . --tables --json
python tools/scripts/validate/check-p13-retention.py --repo-root . --draft-tables <full-source-sha>

--tables audits p13-table-inventory.json; --draft-tables regenerates it and writes nothing. Regenerate it whenever a migration adds, renames or retires a table, and review the diff. Runtime source and its project ownership also participate in this comparison. The checker reads the existing build-boundary ownership manifest; a missing source-owner classification fails. Compile item includes/removals and linked files are accounted for. Conditional build variants are covered conservatively: a conditional removal cannot hide a writer present in another variant.

p13-table-proofs.json contains reviewed, hash-bound source references for each claimed operational-state table. All five obligations are mandatory: terminal predicate, window, size budget, Doctor check and active-row safety test. References bind the implementation and its configuration/Doctor registration as applicable. Removing an obligation or changing a referenced source fails before an inventory can claim that class. A lifecycle DELETE alone now remains pending. The checker validates references; it does not execute those referenced tests or infer live pruning.

What “active” means, and how it is resolved

A table is active when both hold, each resolved from source rather than asserted:

  1. A project that embeds its creating migration as a resource (<EmbeddedResource Include="Migrations\**\*.sql" />, expanded as MSBuild item globs, not shell globs) is in the ProjectReference closure of at least one deployable host project — OutputType Exe, or the Web/Worker SDK’s executable default.
  2. The owning library’s own migration chain has not retired it. Chain order is file-name order, which is what MigrationRunner applies (OrderBy(Path.GetFileName)), not repository path order. A DROP TABLE later in that chain removes the table from the census; the retired scanner.epss_* parents are the worked example. Inside one file, statements count in text order: a DROP TABLE followed by a CREATE TABLE of the same name leaves the table active. An ALTER TABLE <from> RENAME TO <to> retires <from> and makes <to> active in <from>'s schema. The renaming file then counts as a creator of <to>. That holds even when the chain never created <from> itself, as when Timeline 000 renames the legacy heap to unified_audit_events_heap_rollback. A branch inside a DO block counts as if it always runs. A rename target inside a string literal or built at run time is not read.

A partition child is not counted as its own table. It is storage for its parent and inherits the parent’s class; counting it separately would inflate the census and invite a second, conflicting class on one logical table.

How a class is earned

A historical retention-class: label never assigns the effective class. The estate’s labels are largely outside the closed five-class vocabulary (operational, rebuildable, derived, audit, windowed, evidence, and more), and Q-26 reclassifies by data semantics, not by the old word. So the effective class comes only from evidence a reader can re-check:

ClassEvidence required
operational-stateA lifecycle DELETE plus the complete five-obligation source reference set in p13-table-proofs.json. A matching status token alone cannot establish lifecycle semantics or compliance.
append-auditThe table is declared PARTITION BY RANGEand a routine drops aged partitions by a name built at run time (format('DROP TABLE IF EXISTS …%I', …)). LIST/HASH do not qualify — see below.
any of the fiveA reviewed forward determination: an effective-source disposition in p13-source-inventory.json carrying that class’s full proof set, admissible for the shape the detector already inferred (P13-2).
anything elsepending, with a reason from a closed vocabulary.

Tenancy partitions are not retention windows (P13-4)

PARTITION BY RANGE (created_at) is a retention dimension. PARTITION BY LIST (tenant_id) is an isolation boundary whose bound is the tenant lifecycle, and it has no time dimension to declare a window over. The detector records the strategy and key expression, and LIST/HASH parents fall through to the ordinary class branches to be classified by their writers like any heap.

Two things were wrong before that, and the second is the one that mattered:

An inhparent = '<qualified>'::regclass anchor beats proximity, and its scope is the enclosing $$ … $$ routine body rather than a character radius. timeline.unified_audit_events names its parent 727 characters from the run-time-built DROP; a radius-bounded search found nothing there and credited a PL/pgSQL record field as a table instead. Proximity remains the fallback for routines that declare no anchor.

partition is optional for append-audit. 47 of the 51 tables in this estate labelled append-audit are not partitioned at all, and that is often correct: signer.key_audit_log carries a seven-year window enforced by signer.prune_key_audit_log(interval), which SignerAuditRetentionService calls daily (it had no caller before RFC-14), and an ON DELETE CASCADE custody boundary. retentionWindow and custodyBoundary stay mandatory, the set stays closed, and a table that IS partitioned must still cite its partition.

Adopting a populated DEFAULT partition (P13-10)

A table declared PARTITION BY RANGE whose only child is the DEFAULT is not partitioned in any useful sense: nothing can be dropped, so nothing is ever reclaimed. facts.fact_revision sat in that state from its baseline until P13-10, with 17.5M rows in the default.

The obvious repair — create ranged children and copy the rows across — is the expensive one, and it is what made this look like a data migration. PostgreSQL refuses to create a child whose range overlaps rows held in the DEFAULT, so the rows appear to have to move first.

They do not. DETACH PARTITION is metadata-only, and a detached heap can be re-attached under bounds that cover the span it already holds:

  1. LOCK TABLE … IN ACCESS EXCLUSIVE MODE — taken BEFORE the span is measured, so a row inserted between the measurement and the re-attachment cannot fall outside the bounds and fail it;
  2. measure date_trunc('month', min/max(<key>));
  3. DETACH, rename to a legacy child name, ATTACH … FOR VALUES FROM (min) TO (max + 1 month);
  4. re-create an empty DEFAULT as the catch-all.

Zero rows move and no WAL is generated for the data; the cost is two sequential scans. Measured on the lab estate: 5,444 MB / 17,546,474 rows adopted with a byte-identical content digest before and after.

The reclaimer must then drop by MEASURED bound, not by a name it rebuilds from a month. The adopted child spans however many months the default had accumulated, so a name-keyed pruner would never match it and its rows would be retained forever. Read pg_get_expr(relpartbound, oid) and compare the real upper bound, keeping the parent named through inhparent = '<schema>.<table>'::regclass inside the routine that does the drop — which is also the anchor check-p13-retention.py credits.

Two consequences worth stating before copying this pattern.

Adoption also replaces the OfflineKit 006 template’s emptiness guard rather than dropping it. A RAISE … 55000 on “the default is not empty” would refuse exactly the situation the migration exists to fix, and on a populated estate it stops the service from starting. The raises are kept for what adoption genuinely cannot do: a span that overlaps an already-attached child, and a rename target that already exists. Recovery for both: docs/runbooks/fact-revision-partition-adoption.md.

The forward determination path (P13-2)

Shape inference cannot answer every question, and the pending reasons say so in their own words: a bounded window “cannot separate operational-state from a windowed append-audit or a TTL cache; the owning family must name the class”. apply_source_declarations is how a named class reaches the table census, and it is constrained in five ways so that naming is not the same as asserting.

  1. Admissibility is keyed off the shape the detector already inferred. ADMISSIBLE_DETERMINATIONS maps each pending reason to the classes a determination may assign, and each empty tuple is a named refusal: partitioned-without-declared-window belongs to P13-4, unresolved-schema needs identity repair first, and operational-obligations-unproven belongs to P13-3 — re-declaring a class in a migration must never substitute for proving it. no-retention-evidence refuses operational-state outright: nothing has been observed reclaiming the table, so there is no source to cite.
  2. A determination may confirm a shape-decided class, never contradict one.
  3. The proof set is the same one audit() demands, from the single class_obligations() definition both gates read. An operational-state determination additionally carries its five obligations into the census and is verified by the same verify_operational_obligations a shape-earned row is.
  4. The evidence is derived, not authored. declaration_span recomputes the contiguous COMMENT ON TABLE <subject> … retention-class: <class> text with the matchers observe_file uses, so a bridged row’s evidence pins subject and class in one substring a reader can find in the file.
  5. Four structural guards. A source-of-truth identity must live in a file that actually creates the table — this caught a committed proof that cited its own label as its identity. A rebuildPath must be a non-test .cs under src/. The determination must sit in the creating chain’s own migration folder and sort after it by file name, the discipline verify_forward_reference already uses. Two determinations for one subject may not disagree.

The bridge runs after apply_table_proofs, and the order matters: that function refuses any registered subject that is not already operational-state by shape, so its input has to stay pure shape inference or it would be validating against the bridge’s own output. Each determination is re-proved standalone rather than by calling audit(), so an unrelated source site drifting cannot turn --tables red.

Two distinctions do the real work, and both were found by wrong answers before they were rules:

The predicate is also bound to its own statement. A fixed character window lends the next statement’s keywords to this one, which is how the demo-seed cleanup DELETE FROM excititor.source_trust_vectors WHERE tenant = 'demo-prod' first read as a retention path.

Owner and database

family is the module root that owns the creating migration; databaseVariable is that family’s STELLAOPS_POSTGRES_<SERVICE>_CONNECTION derived per AGENTS.md 2.7 (module root upper-cased, compounds concatenated; ReleaseOrchestrator is the one recorded exception). A domain-neutral shared library under src/__Libraries/ owns no database variable: its tables land in whichever service embeds it.

ownerResolved is false when more than one host-reachable project creates the table. That is an ownership fact and is kept out of the class reason, so a contested chain can never hide what a table’s classification state actually is.

Why this inventory does not stale the way the source inventory did

The source-site inventory pins line and column and whole-file hashes, and drifted to 315 stale sites in one sprint. A table row pins migration file paths only, and class evidence is a containment reference (path + a literal), not a whole-file hash. Deleting the predicate fails the audit; reformatting the file around it does not.

What it does not establish

Source-only, like its sibling. It does not establish executed pruning, configured numeric budgets, Doctor behaviour, applied migration order on any estate, or live schema state. Runtime definitions have source ownership and complete provenance, but dynamic physical names and actual instantiation remain explicit owner work. A qualified table name shared by two creating families does not prove they use the same physical database; the unresolved owner field remains visible.

--tables --strict returns nonzero while a migration table has a pending class or owner, or a runtime definition still requires classification. Ordinary --tables success means the inventory matches source and its affirmative class claims have the required references; it does not mean the estate complies. Both JSON verdicts report authority: source-only.

Identity register

data-identity-register.json answers “what makes two rows of this table the same thing, and what bounds the table?” It holds exactly one declaration for every table that the table inventory classifies source-of-truth or derived-rebuildable (516 tables on 2026-10-08), under the data identity standard (rules D1-D9). Its shape is documented in data-identity-register.schema.json.

Every entry carries numeric bytes, rows and growth-per-day budgets. The register header states how generated budgets are derived; a hand-reviewed budget states its own basis.

Review backlog. A generated: declaration is unreviewed. The checker accepts one only for a table in the ratchet ledger’s generatedBacklog, the 385 tables generated when the register was created (233 entries and 152 exemptions). The list may only shrink: a table not on it needs a hand-reviewed: declaration, so a new table never passes on a generated draft, and the checker fails on a listed table that is no longer generated.

Identity and payload rules. These apply to every hand-reviewed: declaration, so to every new table (SPRINT_20261007_062 DIP-12):

identity.source records explicit origins: upstream-content-address for an authoritative producer’s subject address, and assigned-record for the owning service’s operator/system record. The origin names the producer and contract or allocator. The complete key, including tenant or partition columns, must match identity.key; document coverage is explicit per column. Origin, ingress validation, continuity and the four owning test obligations each carry source witnesses: active C# path, qualified type (optionally ::member), exact snippet and normalized source SHA-256. The schema defines their exact shapes. The form is exclusive with canonicalizer, fixturePair and byteIdentity; it never removes a real canonicalizer’s fixture obligation.

The checker validates those witnesses as source custody and textual anchors. It does not prove symbol binding, reachability, identity semantics or that tests ran. Review and executed native tests must establish that the actual writer preserves the admitted identity, rejects invalid or mis-scoped input, and implements its stated update/replay contract. An OCI address need not be recomputed by a projection that has no image bytes, but a local hash of a regenerated document cannot be relabeled upstream. Assigned IDs do not excuse broken natural-key replacement or repeated payloads. Signed time/randomness and algorithm inputs retain their original meaning. Missing or stale proof grants no source justification, and uncovered documents remain violations. Payload, supersede, fan-out, custody, budgets and guarded-upsert checks apply unchanged. A maximum from a bounded row sample is not a global or future payload-repetition bound.

The existing fact and binary algorithms remain canonicalizer declarations: VulnFact.ComputeFactId binds issuer, source statement, vulnerability, product and applicability; its revision values do not redefine the fact. BinaryIdentityKey.Compute binds the versioned build-id scheme/value and exact file digest, preserving different file variants that share a build ID. Their provenance and package-map references retain the complete natural keys. Symbol catalog membership references the already admitted SymbolManifestStore semantic manifest identity; the separate blob catalog’s raw object address uses the bounded declaration below. These declarations do not change algorithms or settle independent payload-sharing, capacity or materialized-home questions. In particular, FactWriter.ProjectBinaryFactsAsync writes the binary lookup directly; its existence does not prove that a view or expression index is unsuitable.

owned-object-address is the narrow third origin for a local immutable object catalog. It requires one named SHA-256 scalar bytea digest-key component, an actual text object-key column, empty document coverage and a metadata-only table. JSON, arrays, other binary/document payloads, a second digest-like key and unsupported metadata types are refused. Only its objectStore witnesses may name the fixed neutral ObjectStorage selection, FileSystem/S3 write/read and digest-validation members; its producer, registration, continuity and tests still belong to the data owner. The owner API controls must prove exact-byte distinction, same-address replay without a new row version, scope/address binding, tampered reads and refused activation. Source anchors remain textual proof; a fake S3 adapter test is not live durability evidence. symbols.blob_catalog is the first reviewed consumer: raw object bytes have their own address, while symbols.manifest retains its distinct canonical semantic identity. No stored bytes, keys, migration or retention rule change.

Findings’ six corpus-manifest references use the upstream form. The owner manifest address hashes the canonical generation and section names, versions, digests and lengths; source key, occurrence time and transfer encoding remain separate publication metadata. Findings verifies canonical manifest bytes and their agreement with the publication before staging a generation or importing its selected sections. Fenced imports preserve the address, tenant match keys remain tenant-scoped, and the match witness must equal the exact locked SBOM checkpoint. Replay, failure and activation state retain their existing meaning. These declarations do not assert a repetition bound for section descriptors, metadata arrays or other projected payloads.

Policy console-export jobs, executions and bundle manifests each serialize their scalar primary key in the complete JSON stored by the owning Postgres adapter. Equal complete owner-written JSON therefore implies equal primary key, which bounds that value to one row. The native store controls vary only the key, inspect actual JSONB equality and key coupling, and replay the same key without adding a row. This source invariant is not a historical-row validation or a bound on subdocuments. Bundle content bytes can repeat across different manifests and retain their separate D5 obligation. The three document-identity exceptions, allocator and tenant-scope concerns, and D3 update semantics remain independent of this complete-payload declaration.

The complete Policy control-register snapshot has a separate bound of one: the owning repository derives its content hash and stored JSON from the same normalized typed snapshot, whose JSON includes the tenant. The database uniquely constrains that tenant/hash pair. Reordered input retains the row; changed tenant or meaningful content changes the key. This does not bound nested controls, validate historical rows, or alter the publication lifecycle events and update behavior.

Findings’ complete Scanner projection payload has bound one because both primary-key fields come from required, case-sensitive fields in that same payload. The tenant must match the consumed partition and the image address must pass the existing strict validation. Absent, null, blank or differently cased fields cannot use an envelope value or a default. Property-order/case-alias controls also verify that equal stored JSON cannot acquire different keys. Exact replay preserves the row; later sequence updates retain the existing semantics. Repeated path arrays remain a separate obligation, and this declaration does not validate historical rows or change retention.

Findings’ runtime timeline uses an assigned-record identity. The enabled HTTP ingestion path takes the tenant from authenticated context, allocates a separate full UUID for the timeline event, and retains the raw trace ID in its payload. The store preserves that UUID and refuses a duplicate primary key; its reader filters tenant and finding. Repeated requests may create different operation records. This is not semantic deduplication or a payload-repetition bound. The trusted repository does not independently reject an empty UUID, so the current allocation boundary is the owner service and endpoint. Timeline append remains best-effort, disabled mode uses null persistence, and fixture authentication/audit isolation proves neither live grants nor Timeline audit delivery. Historical rows, custody and D3/D5 obligations remain separate.

Policy verification policies use the complete assigned key (tenant_id, policy_id). The HTTP create and clone paths admit an operator’s nonblank policy ID within the resolved owning tenant. The Postgres adapter refuses duplicate creation and preserves the original ID and creation time when updating a definition. tenant_scope describes where the definition applies; it is not the owning tenant. The same complete JSON can occur under two tenant keys, so its payload-repetition obligation stays open. Direct store callers retain the existing default-tenant fallback when no Policy context is resolved. The declaration does not turn that fallback, concurrent updates, fixture authentication or historical rows into stronger guarantees.

Policy report snapshots use the existing assigned (tenant_id, policy_id, version) tuple. The rooted publisher resolves the tenant, selects the existing report-policy identity, validates the definition and expected revision, then assigns the next revision. The database’s unique tuple preserves prior revisions; the separate row UUID and semantic policy digest keep their own roles. This proves sequential stale-request refusal and actual duplicate-tuple rejection, without claiming that every concurrent conflict is translated to HTTP 409. Snapshot content and metadata repeat across revisions and retain their separate payload obligations.

Only policy.engine_ledger_exports/manifest_json has a complete-payload bound of one. The owner store serializes the same typed manifest whose ExportId it binds to the global export primary key. Equal complete manifest JSON therefore implies the same key. The actual store accepts the record and line arrays independently; their repeated values stay open. This bound does not qualify the export-ID allocator, tenant access, no-op updates, duplicated representations or historical rows, and no generic bound-one rule is introduced.

Findings’ VEX-override envelopes are keyed by tenant and the sha256: address of the exact compact DSSE envelope bytes. The owner computes that address over the bytes it signs, or over the verified and bound operator-decision envelope it receives. A signed envelope cannot be re-signed, so its exact-byte address is its identity; a canonicalizer would merge distinct signed artifacts. The owner store refuses a missing prefix or a wrong length, and the table’s CHECKs refuse other case or alphabet. The same address under another tenant is a separate row. A replay keeps the stored envelope and creation time and only fills absent decision and key links. The store trusts the producer’s digest and does not recompute it, and envelope_json is jsonb, so the stored value is semantically, not byte-for-byte, the signed envelope. Its payload obligation stays open: equal JSON can be stored under two tenants or two byte serializations.

Findings’ Graph asset-registry projection keeps each Graph event address (asset-event: plus the SHA-256 of Graph’s canonical event seed) and Graph’s canonical payload unchanged, under the event’s own tenant. The projector refuses a blank address or tenant, unknown types and non-hex hashes before any row or checkpoint is written, and the feed must arrive strictly ordered. A replay of a stored address is a no-op that keeps the row and its version. Findings does not check the address format or recompute the address from the payload, so it relies on Graph for both. A clash on tenant, asset and event hash under a different address stops the projection. The plane runs only when Findings:GraphAssetRegistry:Enabled is set.

hashProof is separate from identity origin and supports only scalar bytea, sha256 and width 32. It names the actual producer, verifier and owning width/tamper/determinism tests using the same source-custody witness format. The checker independently derives an exact inline width CHECK from migration source; the JSON cannot assert the DDL result. Missing, permissive, inherited, renamed, recreated or potentially changed constraints fail, as does unsupported dynamic SQL. Only observed non-width literal or identifier-quoted %I RLS/policy, relation-lock and existence-read templates are recognized; unresolved rename/schema destinations and %s table substitution are refused. Dynamic calls must be complete and their command templates entirely literal, including concatenated USING/WITH CHECK clauses. An unknown appended command or template expression is refused. This is not a SQL interpreter or a live catalog assertion. A 32-byte payload without digest-meaning proof remains a payload. The name-suffix route, identity checks and all other obligations remain unchanged.

The violations present when the rules landed are the ledger’s identityRules.exceptions, owned until their sunset by the open rows that identityRules.sprint and identityRules.rows name (since 2026-10-17 SPRINT_20261017_001 DIP-34 to DIP-46 except DIP-42, which owns no subject; before that SPRINT_20261007_062 DIP-20). The checker fails when a named row is DONE or ABANDONED, so a row removes itself from identityRules.rows in the change that closes it. A listed subject that no longer violates its rule fails as stale, so the list always equals the open debt. Generated declarations are outside these rules until they are reviewed (DIP-17); generatedBacklog.owner names the rows that review them (since 2026-10-17 SPRINT_20261017_001 DIP-47 to DIP-51, one per selected family).

The frozen generated allowance expires after 31 March 2027. Its date, maximum and exact table set may only shrink; blocked work does not extend the deadline. DIP-17 selects Scanner, Platform, Evidence, ReleaseOrchestrator and JobEngine for review. The register’s capacityCoverage separately records eight active owner probes and 17 explicit remaining-family deferrals, each with its owner, scope and the same latest sunset. Open declarations within the selected five are not deferred by those other-family records. Passing capacity monitoring is not declaration-review acceptance.

Ratchet ledger. Every shrink-only value of the gate lives in one committed file, tools/scripts/validate/data-identity-ratchet.json: the pinned tables and their latest sunsets, the database and pin interim ceilings and declared peaks, the event-rate ceilings and their sunsets, the review backlog, the ContentGuardedUpsertConformanceTests allowlist counts, and the identity-rule exceptions. The checker compares the register with the ledger. Its --ratchet-baseline mode compares the ledger with its copy at the merge base of HEAD and a base revision (by default origin/main, else main) and fails when a value grows, a name appears or a sunset moves later, naming the owner recorded beside the value. A base without the ledger is reported as the bootstrap. The architecture lane runs the mode against the pull request base or the previous push.

To add or change a declaration:

  1. Run python tools/scripts/generate/data-identity-register.py. It drafts a generated: declaration for a new in-scope table and refreshes the other generated: declarations. It never changes a hand-reviewed: declaration, a pin or a database budget.
  2. Review the declaration against the creating migration and the code that reads and writes the table. Correct it and set its reviewer to hand-reviewed: <who, when, what was read>. For a backlog table, also delete it from the ledger’s generatedBacklog and lower its max.
  3. Run the source gate. When a change shrinks a debt, lower or delete the ledger value in the same change; the gate fails on a stale value.

The two gates make different claims:

python tools/scripts/validate/check-data-identity.py --self-test
python tools/scripts/validate/check-data-identity.py
python tools/scripts/validate/check-data-identity.py --ratchet-baseline [<revision> | <ledger file>]

The source gate (this checker, and DataIdentityRegisterConformanceTests in the architecture pack) is fail-closed. It fails on an in-scope table without a declaration, a refused exemption, a canonicalizer type that does not resolve in non-test src/, a derived home without readers (and no home pin in force), an entry without numeric budgets, a payload column that an entry does not declare, a generated declaration outside the review backlog, a backlog that grew or lists a reviewed table, a pin that names a closed row or a table outside the ledger, a pin past its sunset or later than the ledger’s, a pin measurement of another table, an identity or payload rule violation that is not a listed exception, a stale ledger value, and a declaration for a table the inventory no longer has. It also fails on a database budget that does not name its family or has no event-rate budget, and on an interim ceiling that grew (see below). Its result has authority: source-only: a green register does not mean that the estate meets its budgets.

The live gate is the capacity probe and Doctor check of each owning host (SPRINT_20261007_062 DIP-5). It compares measured tables and databases with the register’s budgets. data-identity-measurements-20261008.json is the dated size census behind the generated budgets; measure again before relying on a number.

Capacity probe, receipts and the writer pause (D8)

Each database budget names the family whose entries it covers. The Vulnerabilities web host, the Policy Engine, findings-web and the five hosts below each embed this register as their budget source and run one leased capacity probe (only one replica probes): after the configured startup delay (one minute by default), and then every 24 hours plus a per-replica jitter. The delay is not a migration-completion barrier; an attempt that encounters incomplete migrations fails and retries after 15 minutes by default, with the missing receipt remaining a Doctor failure. A probe reads catalog values inside one READ ONLY transaction with a 60-second statement timeout: pg_database_size, and per registered table pg_total_relation_size and pg_class.reltuples (a partitioned table sums its partition tree). A second READ ONLY transaction with the same timeout counts the event rate of the eventing ledgers (see Event rate below), and a third counts the duplication lines the register declares (see Duplication lines below). It writes one row to its own database: runtime.capacity_probe_receipt (stellaops_vuln), policy.capacity_probe_receipt or findings.capacity_probe_receipt. These tables are operational-state: each insert also runs prune_capacity_probe_receipts, which keeps the newest 90 receipts and never reclaims the newest one.

DIP-17 adds these owner-local receipts and checks. The new hosts select their explicit owner pool for measurement, receipt persistence and PostgreSQL lease acquisition/fencing. A host’s ambient key-storage pool cannot substitute for it. All five use the normal Capacity:Probe configuration. Their budget register is embedded and all measurements are local to the owner database, so the probe requires no Internet connection or online licensing/telemetry service.

This capacity implementation was verified against 415723c1bc7f9911d48c3df493656f6a6b87a216; the checkpoint receipt separates source tests from continuing declaration review. Re-run the shared control with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/__Libraries/__Tests/StellaOps.Eventing.Reliability.Tests/StellaOps.Eventing.Reliability.Tests.csproj -Class '*CapacityProbeTests' -BuildProjectReferences and the owner-host controls named in that receipt.

Host familyPhysical database budgetReceipt schemaDoctor checkInitial byte envelope
Scannerstellaops_scannerscannerdoctor.scanner.capacity.budget2,845,415,103
Platformstellaops_platformplatformdoctor.platform.capacity.budget1,710,241,471
Evidencestellaops_evidenceevidencedoctor.evidence.capacity.budget1,236,694,719
Release Orchestratorstellaops_release_orchestratorrelease_orchestratordoctor.release-orchestrator.capacity.budget1,891,890,879
JobEnginestellaops_jobengineschedulerdoctor.jobengine.capacity.budget692,074,175

Each new envelope sums the family’s existing table-entry guardrails and its measured physical bytes outside those relations, including indexes, exempt/operational tables and database overhead. The read-only measurements are dated 13 October 2026; their exact arithmetic is in the register’s basis fields. These are initial monitoring thresholds, not production demand forecasts or an approval of unreviewed generated table budgets. Statistics reset times were unavailable, so no growth rate was inferred. The measurements used the installation diagnostic role; they establish footprint, not application-role isolation. Both event-rate budgets use the existing 10,000/day minimum; all measured seven-day totals were below 22. New receipts share the finite database envelope and retain at most 90 rows. The original three families’ budgets, pins and ceilings remain separate.

A receipt holds one line per measured value:

LineAlarm (WARN)Stop (FAIL)
Database bytesbudget x alarm (80%)budget x stop (95%), or the interim ceiling while one is set
Table bytes and rowsentry budget x 80%entry budget x 95%; while a pin that covers budget is in force, the pin’s declared interimCeiling, or else its measurement + 5%
Table growth per daythe entry’s growthPerDaynone: growth only warns
Events per day (eventing.outbox, eventing.inbox)eventRate budget x alarmeventRate budget x stop, or the ledger’s event-rate interim ceiling while one is set
Duplication line (entry budget.duplication)line budget x alarm, or its declared alarmAtline budget x stop, or its declared stopAt

Growth per day is the byte delta to the previous receipt, normalised to a day. It is computed only when the two receipts are at least 12 hours apart. A registered table missing from the database is listed as absent and has no lines. Any missing required table or declared metric fails the capacity Doctor check with its name; absent measurements cannot be reported as a healthy zero.

Event rate. The outbox and inbox are operational-state tables outside this register, so their bytes are not budgeted per table. Their event rate is: a sustained rise is the early signal of a new churn source, such as an identity migration, a re-normalization storm, or a writer that emits events for unchanged state. Every database budget therefore declares an eventRate with outboxPerDay and inboxPerDay. The probe reports, per ledger, the rows written in the trailing 7 days divided by 7. The eventing pruners never reclaim a row younger than 7 days, so the count is complete. The count reads no ledger rows in bulk. Per key group (outbox: stream and epoch; inbox: consumer, stream and epoch) a binary search over the primary key finds the first sequence written at or after the cutoff, and the probe counts the sequence positions from there to the group’s last one. Outbox sequences are gapless per stream and epoch, and an inbox holds one row per sequence its consumer admitted, so positions equal rows. The count costs a few dozen index probes per group, whatever the ledger’s size. Each line stores the 7-day row count beside the rate. An event-rate line warns and fails the Doctor check; it never pauses writers. The budgets are set at 2 x the busiest 24 hours of a steady-state window with no convergence events, rounded up to two significant digits, with a floor of 10,000 events per day; each basis holds the arithmetic.

Duplication lines. Bytes and reltuples show that a table is large, not that it is duplicated. An entry’s budget may therefore declare duplication lines (SPRINT_20261007_062 DIP-14), each a count of rows the standard says should not be there, with its own budget and basis:

LineEntryCountBudget (alarm / stop)
supersededCopies (D2, plan metric M1)ingest.raw_documentrows beyond one per (source_key, upstream_ref); rows without an upstream reference are not counted. Exact: an index-only scan of ix_raw_document_upstream_revision310,000 (248,000 / 294,500)
tombstonesPastRule (D9, M7)facts.fact_currentkey-convergence tombstones the superseded-identity reclaimer would delete now: the reclaimer’s cutoff and revision rule over idx_facts_fact_current_tombstoned, one fact_revision primary-key probe each (a scalar sub-select, never a join); capped at the stop line200,000, one reclaim pass (160,000 / 190,000)
generations (D6.3)findings_security.advisory_matcher_projectionrows of advisory_projection_generation in every state, an index-only count of that table3, declared pair (3 / 4)
overrideRows (D6.2, M6)policy.vuln_gate_currenttenant override rows, per tenant of the gate projector’s tenant catalog under that tenant’s row-level-security context, over the primary key; capped at the stop line1,000,000 (800,000 / 950,000)

The owning host supplies each count (ICapacityDuplicationLine); the shared probe runs all of them in one READ ONLY transaction with no sequential scan and no parallel workers, and hands each count its stop line as a cap. A count whose cost grows with its value stops there, and the line reads “at least” that value. A declared line whose index does not exist, or that no host count implements, is listed absent as table:metric. Each count runs under its own savepoint: one that runs past the statement timeout is rolled back and listed absent as table:metric (statement timeout), and the receipt is still written with every other line. A count creates its commands through the probe’s count session, whose client timeout is the statement timeout plus 15 seconds, so the server always ends a slow count first. With Npgsql’s default 30-second client timeout under the 60-second statement timeout, a count between the two failed the whole probe run and no receipt was written (hub, 2026-10-10). Alarm and stop are the database ratios of the line’s budget, unless the line declares both alarmAt and stopAt for a small count; the checker requires alarmAt <= stopAt <= budget + 1. The Doctor checks report every duplication line on every verdict (evidence duplication.<table>.<metric>.*). A duplication line warns and fails; it never pauses writers.

Interim ceilings. A database that was already above its budget when the budget landed carries an interimCeiling: its measured size plus 5%, the remediation rows that own the excess, and a basis. When the excess comes from a designed peak, the ceiling may declare a peak (at or above the measurement, with its arithmetic) as the base of the 5% instead; stellaops_findings did this for the three-generation matcher projection peak until generation-shared matcher rows (migration 029) brought its designed peak inside the budget and the ceiling was retired. While the ceiling is set it is the stop line. The owner budget keeps setting the alarm line, so the database warns but its writers do not pause on known debt. If the ratios were applied to the ceiling instead, the stop line would sit at 99.75% of the measured size. The ceiling is shrink-only. The source gate fails when it exceeds its frozen maximum in the ratchet ledger’s interimCeilings or its base plus 5%, when a declared peak exceeds the ledger’s, when it does not exceed the budget, or when a remediation row it names is closed. When a ceiling is retired, its database is deleted from the ledger. A pin may declare its own interimCeiling (bytes and rows) the same way, frozen in the ledger’s pinInterimCeilings.

An eventRate whose trailing window still holds a known burst carries its own interimCeiling. For each ledger it covers, the ceiling holds the measured 7-day rows, the rate (rows / 7) + 5%, the remediation rows that own the burst’s tail, and a basis. While set, the ceiling is that ledger’s stop line, and the event-rate budget keeps the alarm line. The source gate applies the same shrink-only rules: it fails when a value exceeds its frozen maximum in the ratchet ledger’s eventRateCeilings or its measured rate + 5%, when a value does not exceed the ledger budget, and when a remediation row is closed. The ceiling also carries a sunset no later than the ledger’s; past it the gate fails, so a ceiling waiting on a blocked row still has a clock. It also fails when a database budget has no eventRate. On 2026-10-08 the trailing window of stellaops_vuln (outbox and inbox) and of stellaops_policy (inbox) still held the key-convergence burst, so those three ledgers carry a ceiling.

Doctor checks. The eight capacity checks described above read the newest owner receipt:

Writer pause (ADR-039 P7). The adopted writer paths in the table below use a capacity gate over their owner database’s receipts. The gate closes in three cases:

The gate opens again on the first receipt that is fresh and below the stop line. It logs one line per transition (closed with the reason, closed for a different reason, open again), never one per poll. The gated writers:

A receipt’s writer_pause flag means writer pause required, not observed admission behavior. The new five-family probes do not by themselves intercept writers; those families’ missing writer-gate adoption remains open. Missing table/metric measurements fail diagnosis without changing the existing database-byte writer-pause predicate. Reclamation and retention continue.

HostWriters that pauseEffect while pausedNever paused
vulnerabilities-worker (receipts from vulnerabilities-web)each ingest cycle and each binary corpus analysis pass; fact key convergence (before each rule and between committed documents; it also waits while the live database size is within Vulnerabilities:FactKeyConvergence:CapacityReserveBytes, 10 GB by default, of the newest receipt’s stop line, because receipts are daily); the consensus projection drain (before each round and on its lease heartbeat); the advisory-metadata backfill and the advisory-metadata revision repair (before each batch); mirror seed publication; binary reference preparationthe cycle is skipped and ingest resumes on the first cycle after the gate opens; a convergence rule or backfill pass stops at a committed document or batch and the worker retries it every 15 minutes, so it resumes without a restart; the projection checkpoint holds (the outbox pruner never deletes vuln.facts rows above it) and the lease is kept; a due seed publication stays pendingraw-document retention, superseded-identity reclamation, superseded-revision pruning, outbox and inbox retention
vulnerabilities-web (its own receipts)operator mirror-seed imports (not validateOnly=true requests), before the upload is staged; first-party issuer submissions, after their allowlist, signature and document checksthe request is refused with 503 problem details titled capacity_writer_paused, the gate’s reason as detail, and Retry-After: 900; nothing is written. Once the gate opens the same issuer submission is accepted; the same import is admitted, though a 409 baseline mismatch or active source operation can still answer it, because those checks need the source fence. The gate never stops an admitted import midwayreads, validation-only seed requests, Doctor checks, database maintenance runs, the capacity probe, outbox and inbox retention
findings-worker (receipts from findings-web)the corpus staging import of a generation whose rows are not imported yetnothing is staged and the corpus checkpoint does not move; the active generation keeps serving and readiness reports Stale (degraded) with the reason, or ColdUnavailable when no generation is activeactivating a generation whose rows are already imported (it retires the old active generation), corpus reclamation, verified-absence deletes
Policy Engine (its own receipts)the hub event drain, the corpus seed and the platform-scope re-derivethe gate checkpoint holds, so gates serve the last derived rows and readiness is unchanged; a first start with no checkpoint stays “not ready” until the seed can run. A long drain re-reads the gate on its lease heartbeat and stops at the next batchthe lease, the exploit-evidence mirror, the tenant VEX decision overlays and the environment-preference re-derive (these follow operator records, and stopping them would keep decisions an operator already changed)

Only database bytes pause writers. A table line, an event-rate line or a duplication line at its stop line fails the Doctor check and pauses nothing.

The same measurement runs read-only from outside an owning host:

bash tools/scripts/validate/check-data-duplication.sh --self-test
bash tools/scripts/validate/check-data-duplication.sh --receipt <file> [--previous <older receipt>] [--note <text>]

It exits 1 when any line is at its stop line, including an event-rate or duplication line, and writes a dated receipt file; --note records why a remaining line fails. It runs the same duplication counts with two differences: the tombstone cutoff uses the hub’s declared 7-day outbox window, and the override count reads all catalog tenants in one statement as the container’s superuser. It executes psql directly through docker exec, without a shell wrapper or a host-side timeout. No CI runner can reach an estate today, so nothing runs it on a schedule. The in-host probe is the guarantee, and the script provides evidence on demand.

Reading the verdicts. Each Doctor check is served on its host’s authenticated /doctor/<service>/checks endpoint. Platform’s doctor aggregate lists it only for a host whose Doctor registration is enabled, and pulls results only when the aggregate is requested; nothing reads the checks on a schedule. One read-only view of all three databases:

bash tools/scripts/validate/check-capacity-receipts.sh            # also a section of tools/scripts/factory-status.sh
bash tools/scripts/validate/check-capacity-receipts.sh --self-test

It prints one line per database with the newest receipt’s verdict, age, database line and worst other line. A receipt older than 36 hours reads UNKNOWN (stale) and exits 1, as does a FAIL; a database without a receipt table reads UNKNOWN (no receipt), because its host is not deployed yet.

Fingerprints and coverage limits

For UTF-8 source, the observer removes a BOM and normalizes CRLF/CR line endings to LF before hashing UTF-8 text. This is a source-text fingerprint, not an applied database migration checksum. Unsupported encodings retain an opaque byte fingerprint and stay explicitly unresolved. The detector’s tooling JSON hash uses sorted-key, ASCII JSON; it is not a product signature format.

The observer scans non-test .sql and .cs source beneath src/. It excludes compiler output, test/fixture trees, _archived migration history and __Obsoleted capability history. It includes unannotated CREATE TABLE sites and marks dynamic, ambiguous quoted or unbound cases unresolved instead of dropping them. SQL comments and C# comment lines are masked for creation-site discovery; this is deliberately a conservative textual instrument, not a full SQL/C# parser, MSBuild evaluator or DI analyzer.

The table census names an unqualified CREATE TABLE by its schema. If the declaring chain’s migrations create exactly one schema, that schema is used. Otherwise the census uses the first entry of the declaring file’s latest top-level SET [LOCAL|SESSION] search_path statement before the declaration, but only when the chain itself creates that schema. A DROP TABLE resolves the same way, so a retirement matches the declaration it retires. A function’s SET search_path attribute clause is not a statement and is not read. pg_catalog and pg_temp are never landing schemas. Any other unqualified declaration stays unresolved-schema. CREATE TEMP TABLE creates a session-scoped table in pg_temp and is not a persistent table, so the table census does not count it.

Some chains carry no schema in their SQL on purpose. Under ADR-039 P4, the migration host binds search_path to the registration’s schemaName. For those chains the census reads a reviewed binding in p13-schema-bindings.json as the last resort. A binding names the migration project, the schema and the registration entry point. It pins with hash-bound proofs every file that declares or calls that entry point, and at least one proof quotes the schema literally. A new caller, an edited pinned file, a binding the library rule already covers, or a binding that resolves nothing fails the census. Such rows report resolved-from-host-binding. A binding establishes identity only. It never assigns a retention class, and it does not cover an operator configuration override outside source.

The same P4 rule forbids a schema literal in the chain’s own COMMENT ON TABLE header, so that header names kek_versions while the census row is crypto.kek_versions. The determination bridge joins the two only in one case: the census resolved the row from a host binding, the header sits in the same migration folder as the row’s creator, and exactly one such row carries that table name (RFC-14, 2026-09-23). A header in another chain, or one naming a row the census resolved some other way, still joins nothing. Every other bridge guard then applies to the joined row unchanged. The self-test carries a positive control and both negative controls.

The Web project’s .angular/, .codex-tmp/, dist/ and storybook-static/ outputs are excluded only beneath src/Web/StellaOps.Web/. Its Angular assets configuration copies docs/ into docs-content, including example SQL; those copies are not persistence source. This does not exclude another service’s dist/, Web src/dist/ or dist-extra/, and it does not authorize new scratch output outside the locations contract. Build copies are left untouched.

Consequently the initial inventory does not establish complete active-table coverage, executable migration order, actual terminal-only pruning, configured numeric budgets, or live schema state. The P13 foundation sprint is archived. RFC-1 through RFC-18 in the active family-convergence sprint own the remaining classifications, identity repairs and source-site repair vocabulary. Source-only acceptance must never be reported as estate compliance.