P13 source classification inventory
Authority: ADR-039 P13 and consolidation review section 4. Delivery: P13-1 through P13-4.
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. 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-10 review covers all 225 contested migration subjects and all 63 runtime creation sites. Two runtime sites remain explicitly unresolved under OK-5; the settled future Platform owner does not prove that the current predecessor writers were 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 current result has inventoryValid=true, ownerResolutionComplete=false and strictCompliant=false. 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.
pending-reviewrequires the exact recorded observation, a reason and a named P13-2/P13-3/P13-4 repair task. It always fails the strict audit. Neither an entire file nor a family is exempted.relocated-pending-reviewpreserves one original declaration’s kind, subject, class and declaration digest while pinning its current source provenance. An origin can be consumed only once, so a move cannot admit duplicate declarations or a new unknown class. It remains pending under the same owner task.effective-sourcerequires the observed canonical class, a resolved subject, full current source provenance, and hash-bound source references for ownership, classification, convergence and the disposal boundary. Class-specific obligations are also mandatory. These are references for review, not executed behavior receipts.superseded-by-forward-evidencerequires a later comment for the same subject in the same project’s embedded migration chain. File-name order matches the migration runner. The replacement must itself pass effective-source proof; a nearby file, earlier migration or unproven target fails.classified-runtime-sourcesupplies canonical classification and full class obligations for a runtime CREATE whose provider has no PostgreSQL table-comment mechanism (for example an offline SQLite artifact). It cannot override an unknown class already present in source.
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.
| Family | Table | Repaired paths |
|---|---|---|
| Risk-bundle jobs | export_center.risk_bundle_jobs | Count cap (durable and in-memory) now ranks TERMINAL rows only. The age window already did. |
| Exception-report jobs | export_center.exception_report_jobs | Age 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 export-center README); 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.
| Table | What was missing | Terminal predicate | Caller | Budget | Doctor check |
|---|---|---|---|---|---|
export_center.audit_bundle_jobs | No prune member existed in either store | Terminal job states, both branches | Worker idle tick and submission | Export:Retention:AuditBundle:* | doctor.offlinekit.retention.bounded-state-budgets |
export_center.mirror_run_cursors | Migration 007 argued no prune was needed | source_id <> ALL(@declared) | Each successful export run | MirrorCursorRetentionOptions.DefaultMaxCursorsPerProfile | doctor.offlinekit.retention.mirror-cursor-budget |
evidence.timestamp_remediation_rate_counters | Migration 005 stated a retention rule and shipped no mechanism | window_start_at < @cutoff | remediation-rate-counter-prune, daily | Evidence:TimestampAssurance:Remediation:RateCounterRetention | doctor.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:
mirror_run_cursorslooks bounded because its key is narrow, and is not: a profile’s source set is editable, so a removed source’s cursor is unreachable forever while the FK cascade waits for the whole profile. The predicate is keyed on the PROFILE’s declared source set, never on the run’s own cursors, and an empty declared set reclaims nothing.timestamp_remediation_rate_countersinvites the predicate “delete a row once its ownwindow_secondshave elapsed”. That is wrong in the unsafe direction. The reader resolves a counter by the window start computed from the CURRENTLY configured rate window, not from the window the row was written under, so shortening that setting can make an older row the current window again — and reclaiming it would reset a live budget, a rate limiter failing OPEN. The cutoff is floored at the table’s ownwindow_seconds BETWEEN 1 AND 86400CHECK ceiling, the only bound no configuration change between two ticks can cross, and a nearer retention is refused rather than clamped.
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/ExportCenter/StellaOps.ExportCenter/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*"
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:
- A project that embeds its creating migration as a resource (
<EmbeddedResource Include="Migrations\**\*.sql" />, expanded as MSBuild item globs, not shell globs) is in theProjectReferenceclosure of at least one deployable host project —OutputTypeExe, or the Web/Worker SDK’s executable default. - The owning library’s own migration chain has not retired it. Chain order is file-name order, which is what
MigrationRunnerapplies (OrderBy(Path.GetFileName)), not repository path order. ADROP TABLElater in that chain removes the table from the census; the retiredscanner.epss_*parents are the worked example.
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:
| Class | Evidence required |
|---|---|
operational-state | A 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-audit | The 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 five | A 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 else | pending, 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:
- Nine Findings tables were being asked for a retention window they have nothing to measure.
- A tenant-disposal
DETACH PARTITIONcould earnappend-audit. Those nine escaped only becauseFindingsTenantLifecycleHandlerinterpolates the table name into its DDL; inlining a literal during any cleanup would have made tenant deletion read as a retention sweep. A self-test control now holds that shut, and it is red-proved.
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) 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.
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.
- Admissibility is keyed off the shape the detector already inferred.
ADMISSIBLE_DETERMINATIONSmaps each pending reason to the classes a determination may assign, and each empty tuple is a named refusal:partitioned-without-declared-windowbelongs to P13-4,unresolved-schemaneeds identity repair first, andoperational-obligations-unprovenbelongs to P13-3 — re-declaring a class in a migration must never substitute for proving it.no-retention-evidencerefusesoperational-stateoutright: nothing has been observed reclaiming the table, so there is no source to cite. - A determination may confirm a shape-decided class, never contradict one.
- The proof set is the same one
audit()demands, from the singleclass_obligations()definition both gates read. Anoperational-statedetermination additionally carries its five obligations into the census and is verified by the sameverify_operational_obligationsa shape-earned row is. - The evidence is derived, not authored.
declaration_spanrecomputes the contiguousCOMMENT ON TABLE <subject> … retention-class: <class>text with the matchersobserve_fileuses, so a bridged row’s evidence pins subject and class in one substring a reader can find in the file. - Four structural guards. A
source-of-truthidentity must live in a file that actually creates the table — this caught a committed proof that cited its own label as its identity. ArebuildPathmust be a non-test.csundersrc/. The determination must sit in the creating chain’s own migration folder and sort after it by file name, the disciplineverify_forward_referencealready 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:
- A static
DROP TABLE demo.jobsis a decommission; a dynamic one is a retention window. Reading the first as evidence credited the002_remove_mishomed_epss.sqlcleanup migration as a retention window for the very tables it was retiring. - A bounded age/TTL window is not a lifecycle predicate. An audit log with a 90-day window, a TTL cache and a job table are indistinguishable at a
created_at <comparison. Reading a window asoperational-statewould relabel immutable history as reclaimable state — exactly the widening Q-26 forbids — so a window alone stayspendingfor its owner underbounded-window-without-lifecycle-semantics.
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.
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 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 matching P13-1 criterion remains open. P13-2 owns forward classification convergence; P13-3 owns retention/budget behavior; P13-4 owns append-audit shape repairs. Source-only acceptance must never be reported as estate compliance.
