rask lance catalog — the spec and the estate's layer above it
Two contracts stack here, and confusing them is how bugs happen:
- The Lance Namespace spec (lance.org/format/namespace/) — operations, error codes, REST grammar. Defines ONLY namespaces (recursive) + tables, and explicitly prescribes no hierarchy enforcement and nothing above a namespace.
- rask's hierarchy —
project > warehouse > namespace > table. Everything above a namespace is OURS: our objects, our guards, our lifecycle. The spec neither requires nor forbids it.
The spec surface (verified 2026-08-04 against lance.org)
- Operations: 54/54 ROUTED, 48 backend-backed.
tests/integration/test_spec_conformance.pyasserts both halves — every spec op has a served route, and the vendoredlance_docs/ns_catalog/spec.yamlstill carries 54 ops (a shrunken spec would silently weaken the check). The other 6 answer a spec-correct 406 because the nativedirbackend stubs them:backfill_column,alter_transaction,batch_create_table_versions,batch_commit_tables, and BOTH materialized-view ops (docs/COVERAGE.md). Spelling matters when grepping: the spec op and the served route are SINGULAR —POST /v1/table/{id}/backfill_column(spec.yaml:1570,endpoints/columns.py:146) — whilealter_table_backfill_columnsis the native method it wraps.rename_tableis NOT one of them. It is backed in-process and answers 200;docs/COVERAGE.md:15-17carries that correction, dated 2026-08-05. It MOVES A POINTER, never bytes (2026-09-04):dataplane.rename_tableretires the source pointer and re-registers the destination at the source's own location, so version history, row count and the<hash>_<object_id>directory all survive untouched — the V2 layout makes theobject_idsuffix a label, not a resolution path (lance_docs/namespace.md). THE SOURCE IS RETIRED FIRST, and that ordering is the door's race arbitration rather than a detail:register_tableaccepts a second id at a location another id already holds (measured on thedirbackend), so writing the destination first let two concurrent renames of one source both succeed — two live ids on one dataset, wheredrop_tableon either destroys the other's bytes. Retiring first makesderegister_tablethe contended call; a destination that then fails re-registers the source. The spec's minimum is 8 metadata ops; we carry the whole list including versioning, tags, branches, indices and transactions. - Route grammar:
POST /v1/<object>/{id}/<action>— everything a reverse proxy needs (authN/Z, routing) is in the PATH, never only in the body. Path/body id conflict → 400. List ops are GET with query-param pagination; data ops (create/insert/query) are Arrow IPC, not JSON; count/explain return plain text. - Identifier: segments joined by the configured delimiter (
$default) —["a","b","t"]↔a$b$t. The root namespace is the delimiter itself. - Identity headers:
api_key→x-api-key,auth_token→Authorization: Bearer; arbitrary context maps BOTH WAYS through aheader.<name>context KEY —{"header.x-trace-id": "abc"}is sent asx-trace-id: abc(prefix stripped) and every response header returns asheader.<name>(spec.yaml:2471). The oldx-lance-ctx-<key>form is superseded and survives only in the generated per-model docs. Headers beat body fields.
The error contract — NEVER invent a status
The spec defines 24 numeric error codes (0–23) — 22/23 are TableBranchNotFound /
TableBranchAlreadyExists, added with the branch ops — identical across Python/Java/Rust/REST;
clients dispatch on the code, not the HTTP status. On the wire they are RFC 9457 problem
bodies (application/problem+json).
The one rule: an endpoint raises lance_namespace typed errors
(InvalidInputError, NamespaceNotFoundError, …) and lets
service_kit/lakehouse/ns_errors.py::install_problem_handlers translate — it maps all 24 codes
(not-founds → 404, already-exists/not-empty/concurrent → 409, InvalidInput → 400,
PermissionDenied → 403, Unauthenticated → 401, Unsupported → 406 (the spec's own status; Q3, 2026-09-02), Throttling → 429).
The branch codes were MISSING until 2026-08-04 — a missing branch answered 500 on endpoints rask
ships. tests/unit/test_ns_errors_contract.py now pins the map against the ENUM, so the next
spec-added code fails a test instead of a client.
Never HTTPException with a hand-picked status for domain errors — a 422 was shipped once and
no generated client understood it; fixed to InvalidInputError (95ae4cb). NamespaceNotEmpty → 409
is the spec's own error for "container refuses while full" — use it, don't mint one.
rask's hierarchy layer
project (tenant) > warehouse (ONE bucket; a project holds MANY) > namespace (self-nesting) > table
Three layers, each owned in ONE place:
| Layer | Owner |
|---|---|
| shape (what can exist) | the guards in catalog/api/fga_deps.py — require_parent (a table must have a namespace; rename destinations too), require_warehouse_scoped (a top-level namespace only via POST /v1/warehouses/{id}/namespaces; no-op when warehouses_enabled is off — single-bucket deployments have no warehouse to demand), require_project_exists (a warehouse's project must have a registry record — 404 naming POST /v1/projects), require_not_protected (deletion protection — the force rule is under Lifecycle) |
| who | the FGA model's can_* relations (service_kit/governed/auth/model.fga) — the app never invents policy |
| what is possible NOW | the registries, checked BEFORE the native write: project records (catalog/services/projects.py, _projects/<id>.json), warehouse records + top_ns → warehouse_id → root_uri bindings (catalog/services/warehouses.py) |
A tenant EXISTS when its registry record does — not when a warehouse implies it and not when FGA
holds tuples for it. POST /v1/projects writes the record AND the creator's project#admin tuple in
one operation (estate-admin gated on can_observe_events at the root), which is what stops existence
and permission drifting apart. There is no bootstrap exception in warehouse-create any more: one
door, and a warehouse-owner cannot ride a create into project admin.
Check order at every create door: identity (401) → shape (InvalidInput 400) → parent exists
(404) → authz (403) → conflict (409) → native write → tuples (seed_ownership) → events. Guards
run BEFORE native.call — rejecting after leaves a real Lance object with no authz parent.
project and warehouse are control-plane objects, not spec objects — their endpoints
(/v1/projects, /v1/warehouses) are ours. Keep their errors in the same problem-body format so
one client error path serves the whole API.
Storage — what state lives where (and why there is NO app database)
| State | Store |
|---|---|
| table data + versions | Lance datasets on object storage (rustfs) |
project registry (_projects/<id>.json), warehouse registry + namespace bindings |
JSON records on the control root. Id-MINTING creates are conditional (If-None-Match: * via service_kit.lakehouse.records.create_json): project mint, warehouse mint and the write-once bindings are store-arbitrated, a lost race surfaces as 409, never last-writer-wins. Mutable-field read-modify-writes are CONDITIONAL too as of 2026-08-15 — warehouses.upsert_warehouse / set_warehouse_status / projects.upsert_project go through records.mutate_json (read + ETag-guarded replace, bounded retry), so an idempotent re-POST can no longer silently revert a concurrent quarantine or protection change. put_warehouse / put_project remain as SEEDING primitives with no production caller, pinned by tests/unit/test_registry_writes_are_conditional.py. Protection and trash records are still plain overwrites and correctly so: they carry no read-modify-write, so there is no carried-forward field to lose. |
| authz | OpenFGA on its chart-managed Postgres |
| lineage | AGE (Postgres), chart-managed |
A relational app-DB was removed at P7a and must not creep back for the catalog: registry writes are
admin-frequency, the conditional creates arbitrate the id-mint races, and deletes are bottom-up
single-object operations by design — there is no multi-object transaction to need one. The moment
that changes (atomic cross-object invariants, high-frequency filtered listings), it is a design
decision, not a default. The conditional-put primitive is proven live by TWO cas-marker e2e
suites: test_object_store_cas_e2e.py (Lance's own manifest commits) and
test_registry_cas_e2e.py (the registry seam, contended 8-way).
Lifecycle
Reclamation, scheduling and the GC design live in services/maintenance itself — read
services/maintenance/services/{sweep,optimize,purge,reconcile,index_health,tiers}.py — plus
packages/service-kit/src/service_kit/lakehouse/base_refs.py, the shallow-clone-source pre-pass, which
lives in service-kit rather than the service because the catalog's on-demand maintenance doors
(catalog/services/maintenance.py) must apply the same refusal and cannot import the sweep
before changing anything the sweep, the reconciler or the orphan scan touches. This used to point at
a root open_*.md, which is ephemeral by design: the plan was deleted when its work landed and the
pointer dangled. The per-table/namespace/project POLICY that governs a sweep is
catalog.schemas.PolicyRequest (retention, retain_versions, compact_enabled, interval, target rows),
resolved winner-takes-all — an exact table match shadows the namespace record which shadows the
project record, and the winner supplies EVERY field. Any surface showing an effective policy must say
which record won; an inherited value rendered identically to a set one is how nobody can tell what is
governing their data. The project-scoped surface is home's /projects/<p> § Maintenance.
- Creates are top-down: parent must EXIST (registry), gated on the parent's
can_*. - Deletes are bottom-up: a container refuses 409, naming its contents;
cascadeis explicit; warehouse delete gates on the PROJECT'scan_administer; bucket purge is a separate opt-in; project delete has no cascade at all. force=trueoverrides theprotectedflag and NOTHING else — the FGA gate runs first and identically with or without it. Test both delete doors for force-without-authz.- Recoverable drops are OPT-IN (#75). With
LANCE_TRASH_GRACE_DAYS> 0 (default 0/OFF, because a grace period changes whatdrop_tablemeans for every caller) a drop DEREGISTERS and files a_trash/record;POST /v1/table/{id}/undropre-registers from it;GET /v1/table/{id}/tasksshows the pending deadline (§2.4 per-object task visibility).purge=trueis the explicit opt-out — a caller who means "destroy the bytes now" says so. A recoverable drop does not revoke the table's FGA tuples: the owner is the one person who needs to undrop it, and revoking made undrop unreachable for exactly that caller (found by driving the deployed catalog — the unit tests run FGA off); the grants die with the bytes, at purge or expiry.undropre-registers with a RELATIVE location (the final path segment):register_tablerefuses the absolute URIdescribe_tablehad just reported, and thedirbackend lays tables out flat. The sweep REPORTS expired trash and deletes nothing. COVERAGE.md's old "soft-delete is N/A, time-travel replaces it" entry was WRONG and is corrected: time-travel does not survivedrop_table. The CASCADE is recoverable too (#96): with a grace period,drop_namespace(cascade)never issues the destructive native call — a trash record pointing at bytes that call deleted would be a lie — and instead DETACHES the subtree (every table deregistered, namespaces emptied deepest-first then dropped, onekind-tagged record each, shared drop-timeexpires_at; tuples KEPT, the same #75 rule).POST /v1/namespace/{id}/undropis the PLURAL undrop: rebuilds every trashed namespace under the id shallowest-first, re-registers every trashed table (relative-location form), resumable (exist_okcreates; already-registered = recovered);GET /v1/namespace/{id}/tasksshows the subtree's deadline;purge=trueis the same explicit opt-out. A RESTRICT drop stays unrecorded on purpose — it only ever removes an empty manifest row. Declared-only tables (no recorded location) are skipped by undrop with a warning: no bytes were lost. - Protection covers EVERY rung since #73 (2026-08-04): warehouses/projects carry
protectedon their registry records; tables/namespaces carry it as a control-root_protection/record (service_kit.lakehouse.protection) gating drop/deregister/rename (table) and drop (namespace) — deliberately NOT schema metadata, so unprotect is never reachable through the properties door, toggling never creates a table version, and the guard answers even for a corrupted dataset. Set viaPOST /v1/table/{id}/protection//v1/namespace/{id}/protection, owner-gated (protectionmaps tocan_drop/can_deletein_OWNER_SUFFIX_RELATION— an unmapped suffix falls to writer tier). The record dies with the object: drop/deregister clear it so a reused id can't inherit it. A destructive CASCADE destroys its children INSIDE one native call, so they never re-enter this door —drop_namespacetherefore enumerates_collect_descendantswheneverbehavior=cascade(no longer gated onfga_enabled) and protection-checks EVERY enumerated id before anything drops, refusing 409 and NAMING the protected descendant;forceturns the subtree lock exactly as at the named rung, and on the force path the descendants' protection records are cleared after the drop (the same reuse rule as the named rung). Until that landed the docstring promised cascade coverage the code did not have. Trash records for the children landed with #96 — see the recoverable-drops bullet. - NO EXISTENCE ORACLE on destructive doors (audit #4).
delete_warehouse,delete_projectand_set_warehouse_statusall collapsePermissionDenied → TableNotFound, so "not yours" and "does not exist" are byte-identical and the door cannot enumerate ids. CREATE doors deliberately do the opposite (require_project_exists404s namingPOST /v1/projects) because that 404 is a fix the caller needs. Class rule, not a per-endpoint judgement call. - A purge must prove sole ownership first. One project may back two warehouses with one bucket
(
projects_claiming_bucketsubtracts the caller's own project on purpose — the work+gold pair), so?purge_bucket=truechecks same-project siblings AND the reserved platform buckets before the cascade. Without it, deleting the work warehouse wiped gold's data. - Anything making a DESTRUCTIVE decision reads
read_bindings(which returns unparseable paths) and refuses on a non-empty skip list;list_bindingsis the tolerant half, for enumeration only. A binding you cannot read is a namespace you cannot see. deactivate= offboarding step one (quarantine; resolver 403s bound namespaces).- Maintenance (compaction +
optimize_indices+cleanup_old_versionswith tags EXEMPT) lives inservices/maintenance(renamed fromcompaction— it does FIVE things, not one: compact,optimize_indices,cleanup_old_versions, reconcile cross-store drift, and since 2026-09-04 BUILD INDICES off the catalog's request path —api/index_work.py+services/index_build.py, its own Dapr topic becauseackWaitis per-component and an index build outlasts a sweep unit) —catalog/api/maintenance_mode.pyis read-only maintenance MODE (503 + Retry-After), not this. The operations are ONE ordered pass per dataset — compact → optimize_indices → cleanup (maintenance/services/optimize.py::compact_one): compaction obsoletes files, the index optimize folds the new fragments back in, and version reclamation runs last. This is exactly what Lance's own guide prescribes — "it's recommended to rewrite files before re-building indices" and "compact_files()followed bycleanup_old_versions()" (lance_docs/guide.md).compact_one's docstring now states the order correctly and may be trusted; the warning that used to sit here ("read the body, not the docstring") was itself stale by 2026-08-16. A policy may skip a STEP (cleanup_enabled/optimize_indices_enabled), never reorder them — which is why they are modules in one service rather than four services each rescanning every bucket. - A dataset URI encodes its TIER in FIVE different places, and they do not agree on which end it
sits.
maintenance/services/tiers.pysizes fragments per tier (bronze 512 / silver 262 144 / gold 524 288 rows — bronze rows are ~1.8 MB page images, silver/gold ~2 KB records, so one row count cannot serve all three). Reading the tier from the wrong segment does not error, it returnsNoneand silently falls back to Lance's own sizing:<bucket>/<project>-<tier>/<table>(nested — tier TRAILS, reduce from the right);<bucket>/medallion/<tier>[-<lane>](the cascade — the child IS the namespace, and lanes are<tier>-<lane>likebronze-<lane>/gold-<lane>, so the tier LEADS, reduce from the left);<bucket>/<uuid8>_<namespace>$<table>(thedirbackend's FLAT layout — namespace and table share ONE directory name, so the tier is inparts[-1], not a parent directory);<bucket>/medallion/<project>$<tier>(the cascade under a PROJECT —project_rootreroutes the medallion base per tenant, so the promoted child is project-qualified);<bucket>/<uuid8>_<tier>-<lane>$<table>(a cascade LANE vended through the catalog — the flat layout carrying the cascade's order rather than the catalog's). Until 2026-08-16 only the first was handled, so measured live, EVERY governed tier read as untiered and the per-tier defaults had never once applied. Layouts 4 and 5 landed later still and are the reason the branch ORDER is load-bearing:medallionhas to be asked before the delimiter test, or a project'sacme$bronzereads as the flat layout, reduces to the namespaceacme, and the widest rows in the estate get Lance's default row count. Onlymedallionmay promote its child; widening that would let a table NAMEDgoldsize itself as gold.NoneIS STILL REACHABLE, so do not read the list as "all shapes handled". Measured againsttier_ofat HEAD: a NESTED namespace in the flat layout (<bucket>/<uuid8>_<parent>$<tier>$<table>, e.g.aa3bed10_acme$bronze$events) reduces the leaf on its FIRST delimiter and yields the PARENT; and a table nested under a cascade lane (<bucket>/medallion/<tier>-<lane>/<table>, e.g.medallion/bronze-media/pages) is layout 1, which reducesbronze-mediafrom the right and yieldsmedia. Neither errors. The estate's rendered URIs all resolve today (chart/templates/medallion.yamlwritess3://<bucket>/medallion/<ns>for every tier and lane, and the catalog vends the flat layout for top-level namespaces), so both are a hazard of the next layout change rather than a live miss — but nesting a namespace or landing a table under a lane is a config change, not a code change, which is exactly how the first three layouts each arrived. - The reconciler reports cross-store drift and deletes nothing until its report runs clean. It runs on
its OWN Dapr cron binding (
maintenance-reconcile-cron), separate from the sweep's — a read-only drift report must not inherit the data-rewriting sweep's cadence. - OpenFGA rejects a bare-type Read.
read_tuples(obj="project:")with no user is HTTP 400 ("the object id and user cannot be empty"), and the wrapper reports it asServiceUnavailableError— so it presents as a permanent outage on a healthy server. To enumerate by type, read the WHOLE store unfiltered and bucket client-side; governance tuples are admin-frequency, and a real estate fits in one page. Measured live 2026-08-04, after four detectors shipped green against a double that accepted the filter.
Gotchas
update_table_schema_metadataMERGES — the spec text ("Replace schema metadata") is wrong about every backend. Probed against a realdirbackend: posting{owner}over{owner, tier}leavestierstanding. So omitting a key cannot remove it, and the spec's request model typesmetadataas a strict{str: str}that cannot carry a null — which left table properties with no delete at all, andstr(None)writing the literal string"None"onto the table. Since #78 anullvalue DELETES the key: no-null bodies stay on the native spec op, a body with any null routes todataplane.update_schema_metadata(pylance'supdate_schema_metadata, the same dialectupdate_field_metadataalready speaks). Neverreplace=True— the map a caller holds came fromread_schema_metadata, which excludeslineage.*, so a replace silently destroys the #21 self-describing coordinates.descriptionis the one RESERVED key (the lakehouse renders it under the table name); everything else in that map is opaque user data.deregisterkeeps bytes ON PURPOSE (external data);dropremoves them. Neither leaves Lance orphans — but partially-failed writes and unpurged buckets do, and nothing reclaims those yet.services/maintenance's orphan pass REPORTS them (MAINTENANCE_ORPHAN_SCAN_ENABLED— it opens every dataset, unlike the rest of the drift report which compares three stores). It is ON in the deployed estate, which is the opposite of what this line said:maintenance/core/config.py:196defaults itFalse, butchart/values.yaml:1356shipsorphanScan: true, and the chart is the single deploy artefact — so "off by default" describes a configuration nothing runs. Read the chart for what an estate does; readconfig.pyonly for what an unconfigured process does.Three Lance file classes look like orphans and are not. A scan that names any of them would drive a reclaimer into live data, and all three were found by running against a real estate, not by reading the layout doc:
_refs/tags/*.jsonare TAGS, which PIN versions (cleanup_old_versionsexempts tagged versions for that reason);.lance-reservedis a structural marker; and a large binary column's bytes live indata/<data-file-stem>/*.blob, a SIDECAR thatdata_files()does not name — the first live run called 29 MB of real page images reclaimable. Conversely_transactions/ *.txngenuinely accumulate forever (the spec keeps one per commit attempt) and nothing prunes them.The FGA-only live seed (
fga_seed_demo.py) writes projects no registry knows — the origin of "ghost projects". The replacement SHIPPED:scripts/seed_estate.pydrives the real doors in hierarchy order —POST /v1/projects→POST /v1/warehouses→POST /v1/warehouses/{id}/namespaces→/declare→POST /v1/access/tuplesLAST — so every guard runs and a state that cannot be reached through the UI cannot be seeded either. A grant whose create failed is SKIPPED rather than written; writing it is exactly how a ghost is made.The control-event vocabulary is a wire contract in three files.
ControlAction/ControlObjectType(service_kit/control_events.py) reach the frontend throughdocs/catalog-openapi.json→frontend/packages/api/src/generated/catalog.ts. Adding an action withoutmake openapi+bun --cwd=frontend run gen:types:catalogleaves the TS client unable to name an event the backend publishes, andtest_openapi_contractfails. Same forTupleOrigin(service_kit/governed/fga.py) — an origin string not in the Literal is atyerror, not a runtime one.The sweep covers EVERY warehouse bucket, not a static list (#81):
run_sweepunionss3_bucketMAINTENANCE_S3_EXTRA_BUCKETSwithwarehouse_records.maintainable_buckets(registry)and callsdiscover_datasetsonce per bucket — a bucket is created by an API CALL at runtime, so a config-time list goes stale by construction. The orphan scan reads the same registry (_scannable_buckets), reporting anIncompleteScanrather than silently narrowing when it is unreadable. Residual: no multi-warehouse run against REAL object storage yet (#80).
A MULTI-BASE dataset leaks, and nothing in Lance reclaims it — upstream-blocked, not a backlog item.
cleanup_old_versionsis ROOT-SCOPED: it reclaims dead files under the dataset's own root and leaves every non-root base alone. MEASURED on pylance 9.0.0 — a dataset withtarget_bases=['cold']landed a data file in an external base; after an overwrite orphaned it, aggressive cleanup (older_than=None, delete_unverified=True) reporteddata_files_removed: 2for the root-owned files andEXTRA_BASE_DELETED = []for the external one, which survived. There is no pylance API that reclaims it, so this cannot be fixed inservices/maintenance; it needs an upstream answer or a bespoke reclaimer that understandsbase_paths, which is exactly the "list the prefix, subtract what is referenced" logic the orphan scan REFUSES to run on these datasets for safety. The same root-scoping is what makes cleanup SAFE on a shallow clone (seeSUPPORTED_FOR_GC) — the property that protects the base is the property that strands its garbage.A JSON index has NO stats, and Lance says so with a PANIC — upstream, already contained. On pylance 10.0.0 every maintenance sweep prints
thread '<unnamed>' panicked at lance-index/src/scalar/json.rs:95:9: not yet implemented, twice, once per Json index. The reproducing call isds.stats.index_stats("lineage_run_id_idx")against either cascade tier (s3://lance-catalog/medallion/{silver,gold}), whose indexmedallion/services/compute.py:: _index_lineage(:250) creates asIndexConfig(index_type="json", parameters={"target_index_type": "btree", "path": ...}). It is a PROVENANCE index, not a search one — the indexed path islineage -> run_idon the R26 consume-layer document, so a governed row can be filtered back to the run that wrote it; nothing in it serves text or vector search. (The name here was_ensure_lineage_index, which has never existed; grepping for it finds nothing and reads as though the index were built somewhere this file does not know about.)not yet implementedis Lance's owntodo!()— stats for the JSON scalar index simply do not exist yet — so there is nothing to fix on our side and nothing to file beyond upstream. It is CONTAINED, and deliberately:index_health._statscatchesBaseException, notException, because a Rust panic surfaces as pyo3'sPanicException, which derives from BaseException and would otherwise sail straight through the sweep's error handling and kill the tick. It logsindex_stats_unreadableand the finding reports "its statistics could not be read, so its health is UNKNOWN — not the same as healthy", which is the honest answer: those two indices are UNMONITORED, not proven fine. Do not "fix" the noise by narrowing that except clause. Note also that a JSON index can only be built on a Binary/LargeBinary column holding real JSONB — raw JSON text bytes failInvalidJsonbatjson.rs:456, and a string column is refused outright atjson.rs:726.lance_ray.compact_filesDOES NOT COMPACT — upstream, and only the distributed path. lance-ray 0.5.0 + pylance 10.0.0:scripts/ray_lance_job.pystage 4 reportscompaction did not reduce fragments: 4->4and exits 1, which is the sole red inmake test. Stages 1–3 pass on the real cluster (distributed WRITE → 4 fragments, distributed INDEX via lance_ray, EVOLVE v3→v4), so the Ray integration itself works. MEASURED 2026-08-16 against the identical shape — 64 rows in 4 fragments of 16,data_storage_version="2.2",enable_stable_row_ids=True, evolved withadd_columns, thentarget_rows_per_fragment=32— NATIVEdataset.optimize.compact_filesreduces 4 → 2. Same dataset shape, same option, opposite result, so Lance is not the defect. The job's call matches lance-ray 0.5.0's signature (compact_files(uri, *, compaction_options, num_workers, storage_options, ...)), so it is not misuse either.tests/e2e-py/test_ray_batch_e2e.pycarries a strict xfail with this reason: switching the job to native compaction would delete the capability the test exists to prove, and strict means the suite goes red — correctly — the day upstream fixes it.CHECK THE TRASH RECORD BEFORE TOUCHING BYTES — including when you are "only looking". The sweep reports
versions_removed: 0on datasets holding versions far older than the retention, and the reason is almost always the F6(d) exclusion: a recoverably-dropped dataset (_trash/record) is frozen until undrop or purge, because the sweep may not rewrite bytes someone can still restore. Diagnosing that count by hand-runningcleanup_old_versions(older_than=7d)on one of them (done 2026-08-16, onbind86-bronze$converge-proof) IS the destructive call the exclusion exists to prevent: it removed 5 versions / 7,567 bytes and destroyed time-travel to v1–v4 on an object that was still restorable. The latest version, row count,publishedtag and undrop all survived, so cleanup was working correctly — the sweep was right to decline and the operator was wrong to override it. Tags are not the check; the trash record is.A medallion tier's maintenance run is emitted only when the sweep does MATERIAL work.
sweep.py::_did_material_workgates the emit onfragments_removed or old_versions_removed, so a correctly-idle estate records nothing — deliberately, since a 120s cron would otherwise flood the graph with no-op compaction runs. Consequence for verification: a stamped medallion dataset does NOT produce a(:Run)-[:WROTE]->(:Dataset)node just by being written and swept. The read half is witnessed in AGE forsilver$emitproof— but no medallion tier has yet been observed emitting, because every sweep since has measuredfragments_removed: 0, versions_removed: 0. That is the gate behaving correctly, not a defect.THE CHAIN HAS THREE PARTIES, NOT TWO, AND THE THIRD IS WHERE IT BROKE. A producer stamping
lineage.dataset_idand a sweep preferring it are necessary and not sufficient:lineage.dataset_idis schema METADATA, and metadata survivesset_column/append_column/drop_columns, so every DERIVED tier silently inherited its upstream's name (measured 2026-09-09: bronze declaringacme$bronzeproduced a stamped silver table, and the empty schema the distributed lane creates its destination with, both declaringacme$bronze). Silver's compactions and silver's per-dataset FAIL events were therefore filed against bronze's node, and attestation O12 passed the whole time — it asks whether a name is stamped, never whose. Closed by making the STAMP own the question:stamp_stage(..., dataset_id=...)re-declares the destination and DROPS an inherited id when unwired, the same rule it already applied to thelineagedocument, andscripts/ray_stage_job.pynow reads theRASK_DEST_TABLEthe work order always shipped. When you check a declared-id chain, check the third party: not "is a name stamped" but "whose name, on the tier that derived it".THE CASCADE'S TIERS ARE GOVERNED — ALL BUT ONE. This bullet said the opposite, and it was the most misleading sentence in the file. It read "the medallion tiers are DATA WITHOUT GOVERNANCE" and concluded "those tiers simply were never registered". At HEAD that is false for silver and gold and true only of the PRODUCER's bronze seed. Split them:
- Every STAGE RUNNER output is a catalog table.
transform.pycallscatalog_register.ensure_stage_outputBEFORE the write — describe, create-if-absent, then take the location from the catalog's own answer — so the tier is atable:object with ownership tuples before a byte lands, and it then publishes the written version throughcatalog_register.publish_stage_output(the catalog's quality gate;workflow.py::_resume_publishis the approval resume of the same call). That is bronze→silver, silver→gold and the media lane — i.e. silver, gold and silver-media. The chart always supplies the URL (chart/templates/medallion.yamlrendersMEDALLION_CATALOG_URLfor producer and stage runners alike), so the ungoverned branch —if settings.catalog_url and to_dataset— is the dev shape, not the deployed one. Symbols rather than line numbers throughout this sub-bullet on purpose: the stage runner is edited often enough that a cited line goes wrong within the week. - The producer's bronze seed WAS the one that was not, and is governed as of 2026-08-29.
medallion/services/produce.pycomposedbronze_urifrom settings (or from the project's warehouse root), calledseed_bronze, and imported nothing fromcatalog_registerat all — sobronze$eventshad no table record and apolicy/seton it could not succeed from either end: the router-level authorize deniescan_dropon an object no tuple names (403), and a principal that passed the gate fell out atpolicies.py'sdescribe_table→ 404 "table has no storage location to police". The head now callscatalog_register.register_written_datasetBEFORE it seeds — theregister_tabledoor described further down, which needs no warehouse and therefore reaches the reserved bucket. It TELLS rather than asks, and that is the one place it departs from a stage runner: its write location is a deployment contract (chart/templates/medallion.yamlrendersMEDALLION_BRONZE_URIand the bronze→silver stage runner'sMEDALLION_FROM_URIfrom one expression, and themedallion.bronzetrigger carries nofrom_uri), so a vended location would leave that stage runner opening a path nothing writes to. The location is sent RELATIVE toMEDALLION_CATALOG_ROOT— the dir backend answers "Absolute URIs are not allowed for register_table" — and a catalog refusal fails the request 503 before any byte is written, rather than seeding an ungoverned tier quietly. (Bronze written by the INGEST plane was governed all along —ingest.catalog_service.ensurecreates namespace and table first.) - A registration is not an arrival, and the cascade head has to know that. The catalog's own
register_tablemarker is aCOMPLETElineage event whose single output isbronze/bronze$events— indistinguishable, on the fields/bronze-arrivalmatched, from a batch landing. Measured: without a filter, one/producefired TWO cascades.ingest_trigger.pynow drops the byte-free catalog operations (register_table,deregister_table,declare_table) as a denylist, so an external OpenLineage producer naming its own operation still fires the head.
What follows below is the state that closure ENDED for the seed, kept because the mechanism it measures is still exactly how the sweep and the namespace lever behave, and because it is what any fix had to satisfy. It remains the live description of any dataset written by something that does NOT register. A policy door does reach the bytes —
set_namespace_policybuilds its path fromsettings.rootandresolve_policymatches a namespace record by directory prefix (rel.startswith(path + "/")), so a record onmedalliongovernsmedallion/bronze; andretain_versionsalone setseffective_older_than = None, which is keep-last-N with no age bound and would sidestep the 7-dayMAINTENANCE_OLDER_THAN_DAYSwall entirely. What blocks it is AUTHORIZATION, measured live 2026-08-16 with a real dex bearer:POST /v1/namespace/medallion/policy/set→ 403 "can_delete required on namespace:medallion", andPOST /v1/table/bronze$events/policy/set→ 403 "can_drop required on table:bronze$events". Both 403 rather than 404 because the gate runs before existence resolution — and neither object exists:medallionis not a catalog namespace (it is absent even from the reconciler'sunbound_namespaces, which listsbronze,transcripts_v2and the threeacme-*), andbronze$eventsis not a registered table. With no namespace record, no table record and no parent tuple, NO principal can holdcan_delete/can_dropon the seed's dataset, so no policy, protection or grant can be applied to it. Read that precisely, and note how the scope of this sentence has moved twice: it said "the datasets the cascade writes", which over-claimed once the stage runner outputs were registered, and it then said "the seed", which over-claims now that the head registers too. The seed was never UNMAINTAINED, only un-OVERRIDABLE, and it is neither today. The sweep covers them like everything else under the platform's own settings —MAINTENANCE_OLDER_THAN_DAYS(7),tiers.py's per-tier fragment sizing,optimize_indices— which is why every live summary counts them among its 27 datasets and reportsindex_findingsformedallion/{silver,gold}. What was unavailable is the TENANT-facing layer — a per-dataset policy override, a_protection/record, an FGA grant — which is precisely the trio registration restores, and precisely what any unregistered dataset still lacks. "No table record" is a STATE, not a law — and the door that fixes it is already wired.register_tableis precisely for data written outside the catalog's own doors: it turns written bytes into atable:object, seeds ownership tuples, and every governed path (protection, trash, credential vending, the FGA doors) keys off that object. It needs no warehouse, which is why it works in the RESERVED bucket — proven by shipped code (#88), not theory: a gold tier was registered there and its lane ran live end-to-end. So the reserved bucket blocks the WAREHOUSE route (a tenant claiming platform storage) while leaving the REGISTRATION route open (naming an individual dataset) — two different mechanisms, and conflating them is how you conclude the cascade can never be governed. THERE ARE TWO SEAMS INcatalog_register.py, and which one a writer uses is decided by who owns its location. A STAGE RUNNER asks:ensure_stage_outputdescribes the table, CREATES it through the catalog's own door when absent, and returns the location the catalog vends, which the stage runner then writes to (rule I2 applied to the write side) — correct because nothing else names where a stage runner's output lives. The CASCADE HEAD tells:register_written_datasetattaches the URI the producer already owns, relative toMEDALLION_CATALOG_ROOT, treating 409 as convergence only after adescribeCONFIRMS the catalog governs that same location. The telling form was deleted once, when its only caller was a stage runner that should have been asking (andrelative_locationandMEDALLION_CATALOG_ROOTwent with it); the direction was never the defect, the CALLER was. Registration belongs to the CASCADE, which is why the module is workload-neutral and takes only an id and a URI or schema: every lane gets it, or the first workload built is the only governed one. Neither seam mints a namespace — a top-level parent belongs to the warehouse, andrequire_warehouse_scopedrefuses one outright BEFORE the existence check, so a lane that tried it dead-lettered every hop. **And that is DELIBERATE — the platform refuses- Every STAGE RUNNER output is a catalog table.
…(truncated)