Skip to content

shenas floor partition

Changing this page. Any edit to a custody row, to a syncable row, or to the evictable declaration in section 5 requires review by shenas’s security owner before it merges. Section 11 says what else a change requires.

SSP/1 section 15.1 requires a conforming mesh to define a floor: a partition of its tables into three categories, fixed by the implementation rather than by configuration, identical on every node, with the never-syncable category structurally unrepresentable in policy rather than merely denied by default. Registry section 55.1 item 3 makes documenting that partition a conformance requirement.

SSP/1 deliberately does not enumerate which schemas fall where, because that is a property of the application rather than of the protocol. This page is that enumeration for shenas. Section 17 puts a second declaration in the same place for the same reason (which categories may have their history evicted), and section 5 is that one.

It is normative, not descriptive. Two implementations enforce it, one in Python and one in Rust, and each is bound to this page by its own test that fails in both directions, so the tables below are the source of truth and the code is the enforcement. Section 8 records why there are two hand-written expressions rather than one generated one.

The floor belongs to the application’s persistence layer rather than to a replication layer because it is a partition over the DuckDB schemas the application declares: a property of this application’s storage, which outlives any particular protocol that carries rows off the device. Section 6 says what that means while no such protocol is registered.

Relationship to the v1 scoping decision. shenas’s own architecture decision on mesh sync scoping (its ADR 0008, not published) fixed a v1 floor in which only first-party user content and canonical metrics may replicate, and credentials, raw upstream data and node bookkeeping may not. This page does not widen that floor. It restates it at table granularity in the SSP three-category shape and closes two gaps the decision stated but the code did not enforce (section 7).

Category Policy may reach it Meaning in shenas Cost of moving a table out
syncable Yes May replicate, subject to a policy row. Membership is permission to write a policy row, never replication itself: resolution still fails closed with no matching row. Narrowing. Normal change, security review.
never-syncable No Structurally excluded. A policy row naming the schema is refused at write time, the producer raises, and the receiver drops and logs. Widening. Floor revision (section 11).
node-local-derived No Reconstructible on any node from data that node already holds. Replicating it is waste, and for index-shaped tables it is wrong: the pointers dangle on a receiver holding a different set of rows. Widening. Floor revision (section 11).

The partition is total: every table is in exactly one category. Because only one category is policy-reachable, a table that is “not syncable yet” resolves to never-syncable unless it is genuinely locally reconstructible. That makes the word never carry two different weights, so every row also declares a band recording why it is where it is:

Band Applies to Meaning
custody never-syncable Credential, key, session, or trust material. A single mistake here is unrecoverable. Reclassification is never on the table.
node-local never-syncable Describes this node (install state, run ledgers, transport bookkeeping, telemetry), not the user’s data. Replicating it would be meaningless or actively wrong.
deferred never-syncable Replicable in principle; excluded by the v1 floor until a named condition is met. Structurally excluded today exactly so that a misconfiguration cannot pre-empt that decision.
derived node-local-derived Rebuildable locally. Rebuild may cost time or a paid third-party call; that is a cost argument, not a replication argument.
user-data syncable The user’s own content, canonical or user-authored.

The enforcement layer is binary (a table either may cross the mesh or may not), so the code does not carry the band. The band is a decision record: it tells the next reader what kind of argument would have to be won to move a row, and it stops ui.hotkeys from being read as key material because it shares a category with auth.*.

Every table in a schema takes the schema’s category unless section 4 names it. Schemas are the DuckDB names the application declares.

Every declared schema MUST have a row here. Section 2 claims the partition is total; this is what makes that a rule about the table below rather than a property assumed of it. A declared schema with no row here is undocumented, which fails both that claim and registry section 55.1 item 3, even though it fails closed at runtime, because the floor denies what it does not recognise. A stated constraint that nothing asserts is a comment (section 7), so the rule is asserted rather than asked for: adding a schema without adding a row fails the Python binding test.

Every row here MUST name a declared schema. This table and the schema declarations are a bijection, asserted in both directions, with no exception. The converse direction went unasserted for a long time, and a row naming a schema nothing declared passed CI until it was noticed by reading. A first fix let a row name an undeclared schema provided it also named the work item that would declare it. That escape hatch is gone, because its invariant is not one a test can reach: whether the named work is still open, and whether it will ever create the schema, are facts outside the source tree, and the hatch rotted twice for that reason. A schema is either declared or it has no row here.

Deciding a category ahead of the code is still legitimate, and the SOVM note below still does that for the sovm.* tables that do not exist yet. It is prose, which claims nothing about the enforcement layer, as against a table row, which looks enforced whether or not it is.

Schema Category Band Rationale
analysis never-syncable deferred Hypotheses, findings and promoted-metric lineage are user-authored intent and would reasonably follow the user. Excluded in v1: findings bind to inputs that may not exist on the receiver, so a replicated finding can be unfalsifiable there.
auth never-syncable custody Per-source OAuth tokens and API credentials. The archetype of the category.
cache node-local-derived derived Geocode, reverse-geocode and the generic transformer key-value store. Rebuildable by re-issuing the query. Rebuild costs a paid API call; that is a reason to build cache sharing, not a reason to replicate. A table that cannot be rebuilt must not live in this schema.
catalog syncable user-data User-authored category sets, values, geofences and resource annotations. Classification is meaningless on a peer without them.
config never-syncable node-local Per-plugin configuration singletons, the dlt ingestion ledger and landing-zone staging. Describes this node’s plugin installation and its run history.
datasets syncable user-data Canonical, normalized metrics: the query floor every device renders from. Table-level exceptions in section 4.
entities syncable user-data User-authored entity graph and the reference data every other synced row links to. Table-level exceptions in section 4.
inputs syncable user-data First-party user-authored content; source of truth; small. Named surfaces until the schema was renamed for what it holds rather than for the plugin kind that declares it.
media never-syncable deferred Opaque media bytes, on-device only by construction. Bulk binary belongs on the object plane (SOP/1), not in the change-event stream.
plugins never-syncable node-local Installed-plugin registry and per-plugin sync history. A replicated install row asserts an install the receiver does not have.
shenas never-syncable custody Local users (password hashes, remote tokens), sessions, and device-wide settings.
sources never-syncable deferred Raw dlt-loaded upstream data. See section 9 Q3: this is deferred, not derived – raw tables are re-ingestable at the origin API with that node’s own credentials, which is not the same as reconstructible from local holdings.
sovm never-syncable custody The app’s SOVM bookkeeping, today only sovm.projection_checkpoint. custody by the standing rule below, which fixes the band for the namespace rather than for its present contents: the checkpoint alone reads as node-local, but a schema default governs every sovm.* table added after it, and the rule was written for the ones that would carry trust configuration. Both bands enforce identically, so the choice records intent and costs nothing. Replicating a checkpoint would close a feedback loop: this node’s projection progress applied by a peer and projected back. The node’s own durable state is not in DuckDB at all; see below.
streaming never-syncable node-local Change-feed log, per-consumer cursors, per-dataset retention floors and subscription audit. All of it describes this node’s consumption of its own datasets.
telemetry never-syncable node-local OpenTelemetry spans and logs. This node’s operational record, and an exfiltration channel if it ever crossed the mesh: span attributes carry query text and error payloads that no schema constrains.
transforms never-syncable deferred Transform instances (user-supplied SQL, reasonably portable) and step-level run history (per-node). Excluded as a schema in v1 because splitting the two is a widening decision, not a documentation one.
ui never-syncable deferred Hotkeys and workspace layout. Would reasonably follow the user; excluded in v1 pending a product decision on cross-device UI state.

The SOVM layer, and how little the sovm row covers

Section titled “The SOVM layer, and how little the sovm row covers”

The row above governs one table and must not be read as governing the SOVM layer. Almost none of that layer is in DuckDB.

sovm once carried a row on the expectation that the SOVM layer would keep its identity, key and transport bookkeeping in DuckDB alongside everything else. It does not. The node’s durable stores persist to files by atomic rename: the HLC high-water mark, the retained event log (under authenticated encryption), and peer cursors and applied watermarks. Device identity and the keyring take the same file-backed shape. None of it is a DuckDB namespace, so none of it is reachable by the floor, by the write path, or by anything else this page governs.

File-backed is the better outcome and not merely what happened. Keeping the keyring, the HLC mark, the retained log and transport state out of the application’s DuckDB file puts the replication layer’s own trust configuration outside the namespace the write path scans, out of reach of a mis-registered sink, and out of the blast radius of a DuckDB-level compromise. That makes never-syncable structural here rather than enforced, which is the stronger form and the one SSP/1 section 15.1 is reaching for.

What did come back into DuckDB is the one piece that is not the node’s state but the app’s: sovm.projection_checkpoint, an opaque token recording how far this database has been projected into. It belongs beside the rows it describes because it describes them: copy or restore the file and the checkpoint arrives at the position those rows are actually at, which a file written next to the node’s own state would not. It is a single row, opaque to the app, and deliberately not decomposed into the HLC’s parts: the app never compares HLCs, and a column pair would invite exactly that.

Its exclusion is structural, not an exception. The floor’s set of syncable schemas does not name sovm, and an unrecognised schema resolves to never-syncable, so the table is out of the floor without any rule naming it: the same shape as the file-backed stores above, one layer up. A test pins that it is out for that reason, and fails loudly if someone widens the set to include sovm.

The decision the original row recorded still stands, and now covers the tables that do not exist yet rather than the one that does: if the SOVM layer ever moves its identity, key or transport bookkeeping into DuckDB, those tables are never-syncable / custody, and adding them is a documentation change rather than a decision. SSP/1 section 15.1 names transport state, and anything leaking the replication layer’s own trust configuration, as the structural core of that category; replicating the policy store would additionally let a receiver widen its own feed, which the sender-side design exists to prevent. That rule is why the schema default above is custody even though the checkpoint alone would not have earned it.

Table Category Band Rationale
datasets.entity_state never-syncable custody Inferred per-entity state. A stated negative constraint from the security review of the state subsystem: the substrate ships no egress path of any kind, and the mesh is an egress path.
datasets.entity_state_consent never-syncable custody Per-dimension explicit opt-in flags. Consent is granted on a device, about that device’s inferences; replicating the grant would broaden it silently.
datasets.entity_state_deletion_log never-syncable custody Deletion audit for the state substrate. Replicating the record of a deletion re-exports what the deletion removed.
datasets.lineage never-syncable deferred Row-level lineage carries (input_table, input_row_keys) pointers into sources.* (never-syncable/deferred, Q3 of the open questions). A peer without those source rows cannot resolve the references. The lineage code itself declares the invariant (“lineage is local metadata only”) and ships no egress path. Security ruling, 2026-08-18. Widening is gated on Q3.
datasets.user_state_suggestion_event never-syncable custody Suggested state values awaiting user acceptance; carries the same values as entity_state.
entities.entity_index node-local-derived derived Per-node uuid -> (db, table, row_id) resolver over whatever tables this node holds. Replicating it ships dangling pointers to rows the receiver may not have. Each node rebuilds it.
entities.places_wide node-local-derived derived A denormalized view over places and their properties, recreated by ensure_places_wide_view. It has no independent rows to replicate, and an inbound event naming it would ask the materializer to write to a view.
analysis.recipe_cache node-local-derived derived Content-hash-keyed cache of recipe execution results. Recorded for completeness: its schema is already never-syncable, but the honest category is derived, which is what a future decision to open analysis would need to know.

Schemas whose tables are created dynamically (auth.*, one table per source; config.*, one per plugin; sources.*, managed by dlt; and the per-plugin metric tables in datasets.*) are covered by their schema default. That is deliberate: a partition that had to enumerate dynamically-created tables would fail open the first time a plugin created one.

SSP/1 section 17 lets a storage-constrained node discard cold history below a per-table HLC horizon, and requires that only categories the implementation declares evictable may be evicted, that the declaration be documented, and that the declaration state that resurrection on a declared category may come back partial. This section is that declaration for shenas.

Evictability is a different axis from the floor, and nothing here widens or narrows section 3. The floor answers whether a table’s rows may leave this device. Evictability answers whether this device may drop its own copy of their history. A syncable table may be retained in full, and a never-syncable table is not thereby evictable.

Declaring a category evictable accepts partial resurrection on the application’s behalf. Two nodes holding the same events under different horizons materialize different rows. A row that resurrects rebuilds only from the history this node still holds, so a row whose covering set reached below the horizon comes back missing exactly those columns whose last write was not the row’s last write: the failure mode is a silently incomplete row, not an absent one. A category whose rows must resurrect exactly MUST NOT appear as yes below. That is how a table opts into full retention, per table rather than mesh-wide.

Schema Evictable Why
datasets.* yes Canonical normalized metrics: high volume, append-mostly, and the one category where retaining every event forever is the constraint that bites first. A partially-resurrected metric row is recoverable – the dataset plugin recomputes it from sources.* at the origin – so the cost of the trade is bounded and local. The table-level exceptions in section 4 are never-syncable and therefore out of this table’s reach.
catalog no User-authored category sets, values, geofences and resource annotations. A geofence that resurrects missing a coordinate column is a geofence that silently stops matching, and nothing recomputes it.
entities no The entity graph every other synced row links to. A partially-resurrected entity is a dangling reference held by rows that resurrect correctly, so the damage is not contained to the row that lost columns.
inputs no First-party user-authored content and the source of truth for it. Small enough that eviction buys nothing, and irreplaceable if it comes back short.

The table covers the syncable schemas and nothing else, because eviction acts on the retained event log and only a replicating table has one. A never-syncable or node-local-derived table has no log to evict and no horizon to raise; its local retention is an ordinary storage question, decided by whatever owns that table, and stating it here would imply an SSP/1 mechanism that does not reach it.

Nothing asserts this yet, and that is stated rather than left to be noticed. No node evicts today, for the same reason no replication layer is registered (section 6). When one exists, the declaration is expressed in Rust as an implementation of the SSP/1 host crate’s EvictableCategories port, alongside the SyncableFloor implementation section 8 describes, and it is bound to this table by the same both-directions test. Until then this section binds in advance, exactly as the SOVM note in section 3 does. Eviction refuses a category this declaration does not name, so the gap fails closed against eviction rather than open.

The floor is dual-sided and fail-closed. Every site below reads from the Python expression of sections 3 and 4; a replication layer delegates to it rather than restating the partition. Section 8 records the second expression, in Rust, and why there are two.

No replication layer is registered today. The earlier implementation that wired these sites was removed ahead of its SOVM replacement, so the floor currently gates nothing at runtime: with no sink registered, every table resolves as not syncable and every write takes the no-sink path. The table below is therefore the contract the next layer wires, not a description of live code. It stays on this page, and the partition stays tested against it, because re-deriving a security floor from prose once there is a caller again is exactly how G1 in section 7 happened the first time.

Site What it does Where it binds
Write path Every table save, insert and delete consults the registered sink’s is_syncable before building a payload, so an out-of-floor table mints no change event and nothing enters the change log in the first place. The plugin table base class, through the change-data-capture seam, to the registered sink
Producer scan The outbound query filters on the floor’s SQL predicate, so the floor is applied in SQL and the sync cursor cannot stall on a run of out-of-floor events. The layer’s change-log scan
Producer assertion assert_syncable raises OutOfUniverseError. A local producer emitting out-of-floor data is a bug, and silence would hide it. The Python floor
Send-side policy Above the floor, a per-(peer, table) verdict decides which of the in-floor candidates this peer receives, denying when no row matches. The floor answers whether a table’s rows may cross the mesh at all; this row answers to whom. It is sender-side and never appears on the wire. SSP/1 section 15.2: a sync-policy table in Python; the PolicyStore port in Rust
Policy write assert_universe_schema refuses a policy row naming a schema outside the floor. This is what makes exclusion structural rather than deny-by-default, as SSP/1 section 15.1 requires. The Python floor, called by the layer’s policy store
Receiver Scope gating on the envelope’s keyed scope tags runs before decryption; surviving events are re-checked per row and dropped-and-logged, never raised, so a hostile peer cannot wedge the apply loop. The layer’s receive pipeline

Table-level exclusion applies at every one of these sites, including the SQL predicate. Excluding a table only on the write path would leave events already in the change log selectable by the producer scan.

The send-side policy row is unfilled in the Rust stack. Unlike the rows above it, this one is not merely awaiting a replication layer: the shenas node is that layer, and it does not consult a PolicyStore. It selects what to offer on the floor category alone, so the second conjunct is absent and a peer holding any grant is offered every syncable table this node holds. The consequence is that the floor is currently the whole of outbound enforcement in Rust, and an operator’s per-table grant names the peer’s write set only. This is recorded as a conformance finding against SSP/1 section 15.2 and is open. The row stays in this table because it is the contract, and a table that omitted the one site nobody wired is how the gap stayed invisible.

G1: the entity-state substrate was stated never-syncable and was in fact syncable. The state subsystem declared its tables never-syncable as a stated negative constraint from its security review, but the floor had no table-level exclusion for datasets.*, so all four tables were in-floor. Before this partition was written (2026-08-16), is_syncable("datasets", "entity_state") returned True, and an insert on that table therefore appended a replicable event. The change that introduced the partition added the exclusion at all four enforcement sites, and the binding test pins the subsystem’s declaration against the floor so the two cannot drift again.

G2: entities.places_wide was in-floor. It is a view, so no local write path emits for it, but an inbound event naming it passed the receiver’s floor check. Now excluded as node-local-derived.

Both are narrowings. Neither requires a floor revision.

There are two expressions of sections 3 and 4, one per language, and there is no generator. Each is hand-written, and each is bound to this page by its own test that fails in both directions: a row here with no counterpart in the code fails, and an entry in the code with no row here fails.

Expression What it is Bound by
Python The application’s floor module, which the write path and the registered sink consult A test that parses the three tables above by their markers
Rust The shenas-floor crate, an implementation of the SSP/1 SyncableFloor port A crate test that parses the same tables the same way

The Rust expression exists because SyncableFloor is what a SOVM node consults on its receive path, and it is a port precisely because which table is node-local is an application fact the protocol crates have no standing to decide. This section is the contract that crate is built to.

Why not one expression, read as data. A partition a node loads at startup is configuration, and section 15.1 requires the floor “fixed by the implementation rather than by configuration”. It also fails the structural half of the same requirement, whose argument is that “a deny-by-default rule can be overridden by a misconfiguration or a widening operation. Structural exclusion cannot.” A file on disk is exactly what a misconfiguration overrides. Shipping the partition as data does not conform.

Why not a generator. Generating both expressions from the tables above is the more attractive option on paper, and it is one this project has already retired once, for this site’s own derived pages. Two costs beyond that precedent:

  • It removes the reviewed artifact. This page routes any change to a custody or a syncable row through security review. Today a narrowing arrives as a diff to constant sets a reviewer reads alongside the prose. Under codegen the reviewed artifact is markdown and the enforcement artifact is machine output nobody reads: strictly worse for the control the band vocabulary exists to serve.
  • STRIDE, Tampering. A generator is a build-time writer onto a security-critical constant set. A test is not: it can only fail the build, never write the artifact. On a partition whose whole job is structural exclusion, adding a build-time writer to the excluding code is the wrong direction.

Why two hand-written expressions do not drift. Two hand-maintained expressions of one normative document is the drift this page exists to prevent, which is the objection, and section 7 answers it. G1 was not two expressions disagreeing. G1 was one expression nothing asserted: the state subsystem declared its tables never-syncable and no test tied that to the floor. The fix was not a generator; it was a test that fails in both directions. Drift needs an unchecked expression, and two expressions each bound both ways to this page have none.

The accepted cost. A floor change edits three files: this page and both expressions. Both tests name the file to edit in their failure message, and CI fails on any two of the three. That is the price, and it is smaller than a build-time code generator on the enforcement path.

Why the Rust expression is not in the protocol workspace. The SOVM crates are meant to ship as a general-purpose workspace of their own, and a binding test inside that workspace would lose its path to this page the moment the workspace was extracted. The floor is the artifact where a test that only passes in one checkout is least acceptable, so shenas-floor sits in shenas’s own node workspace, and one application’s storage layout stays out of a general-purpose mesh workspace.

Q1: datasets.lineage and datasets.weather. Ruled by the security owner, 2026-08-18.

datasets.lineage is never-syncable / deferred. Lineage rows carry (input_table, input_row_keys) pointers into sources.* (never-syncable, Q3 below). A peer receiving lineage rows cannot resolve those references; the records are partial and misleading on the receiver. The state-isolation invariant does not govern lineage directly (it carries no entity-state values), but structural referential integrity does. Widening is gated on Q3. STRIDE: Information Disclosure and Tampering.

datasets.weather is syncable / user-data, confirmed. Place binding is a third-party export concern. In mesh sync the peer already has entities.* (including places) under the fleet key; weather reveals nothing additional. Weather is publicly rebuildable for known places. No narrowing is required.

Why there is no third “optionally syncable” verdict. syncable already is optional, so confirming weather is not a decision to replicate it. Membership grants permission to write a policy row and nothing else (section 2), and SSP/1 section 15.2 denies when no row matches, so weather stays on the device until someone writes an explicit allow. Policy is keyed (peer_site_id, table_schema, table_name, layer) and an exact table beats *, so datasets.weather can be denied on its own while the rest of datasets.* is allowed: a per-table opt-out that needs no floor change. A fourth category would move that choice out of policy and into the partition, which section 15.1 forbids: the floor is “fixed by the implementation rather than by configuration” and identical on every node, so a per-node opt-in is exactly what it excludes. The two verdicts above are therefore “no policy row can ever reach it” (lineage) and “reachable, and off until the user opts in” (weather).

Correction, 2026-09-18: the paragraph above describes a mechanism that is not implemented, and the weather verdict does not rest on it. The conformance finding in section 6 means the Rust node never fills the SSP/1 section 15.2 port. Every clause of that paragraph is therefore false in the Rust stack today: there is no policy row, no deny-on-no-match, no exact-beats-* resolution, and no per-table opt-out.

The ruling stands; its stated ground does not. The security owner re-confirmed datasets.weather as syncable / user-data on the first paragraph’s grounds, which are content-based and do not depend on section 15.2 existing: weather is co-disclosed with entities.* (including places), which is itself syncable / user-data and reaches the same peer regardless of per-table grants, and weather is independently publicly rebuildable for a known place. Both hold at zero section 15.2 enforcement. That confirmation is scoped to the content-based argument and does not extend past the point real user data crosses a link.

The generalization does not survive, and must not be cited for any other table. While section 15.2 is unenforced, syncable is a two-category partition in practice rather than three. datasets.* (excluding weather), catalog.*, inputs.* and entities.* itself have no analog to weather’s two-part escape hatch: each is fully exposed to any peer holding any grant, with no operator-expressible narrowing. The third category is real again when the send-side policy port is filled, not before.

Q2: publication. Resolved 2026-09-30. SSP/1 conformance requires the partition to be documented, and a floor is only checkable by a peer operator who can read it. This page is that publication. It sits among the implementation entries rather than in the specification because section 15.1 puts the partition in the application, not the protocol; an implementation page is a published page about an application, held outside the specification.

Q3: reclassifying sources.*. Gated on the SSP/1 migration. Raw source data is the user’s data and the mission argues for it being portable. Two things must be answered before it could move to syncable: the row-key contract (dlt’s _dlt_* surrogate and load-lineage columns are per-node, so cross-node row identity is undefined) and embedded credentials (raw payloads are unmodelled upstream JSON and may carry tokens no source plugin ever declared). The supported path today is the documented one: promote into datasets.* through a dataset plugin that owns the canonical shape.

  • Failure-closed by default. Every category boundary denies on ambiguity; the receiver drops rather than raises so a peer cannot wedge the apply loop.
  • Blast radius. custody exists as a band because the recovery cost of a leak there is unbounded, and structural exclusion is the only control that survives a misconfiguration.
  • Reversibility. Narrowing is a two-way door and ships normally; widening is a one-way door that changes a mesh-wide invariant and is gated in section 11.
  • Least privilege. Floor membership grants the ability to write a policy row, never replication. Nothing replicates without an explicit allow.

Narrowing (any category to never-syncable or node-local-derived): a normal change with security review. Peers converge safely because a narrower node simply sends less, and the receiver’s own floor was never the thing that admitted the data.

Declaring a category evictable (section 5, no to yes): security review, and treated as one-way. Un-declaring stops future eviction but restores nothing: history evicted under the old declaration is gone wherever every node evicted it, and the rows that resurrect short stay short. The reversible direction is yes to no, which is a normal change.

Widening (to syncable): a floor revision. SSP/1 requires the partition to be identical on every node, so a widening is only safe once every node agrees. It requires a revision of the v1 scoping decision, a version bump that peers can detect, and a backfill plan for the history that was withheld. It is never a configuration change, and it is never done by editing a policy row.