Repository navigation
ciris-persist v57.0.0
[57.0.0] - 2026-10-15
Fixed — tag CI: the restored cache repair runs --strict (v56.0.0 windows wheel)
-
No library code changed. The v56.0.0 tag run's
maturin abi3-py311 wheel (windows-x86_64)failed as v55.0.0's did (out/bindgen.rsmissing under a
trustedlibsqlite3-sysfingerprint) although the v55.0.1 repair ran: it
removed two otherlibsqlite3-sysunits and KEPT the poisoned one as
"unverified, far from the cutoff". The single-cutoff bound assumes one
prune cycle; a unit no re-saving job compiles survives several, and its
file was pruned under an earlier cutoff..github/actions/ciriscachenow
runsrepair --strict: every manifest-less build-script unit is dropped
(build scripts rerun, minutes) until a sealed save replaces the blob.
The v56.0.0 release body was set by hand, as v55.0.0's was. -
The repair's CLI accepts
--strictin either position (repair --strict <dir>is the restore action's form). PR #1093's first CI run printed usage and exited 2 on every job; the self-test now exercises the CLI (19 checks).
Changed — CIRISVerify v20.2.0 re-pin (CIRISVerify#309)
- The seven Rust pins (
ciris-verify-core,ciris-keyring×4 targets,
ciris-crypto, theserverfeature set) move v20.1.0 → v20.2.0 together,
and the wheel'sRequires-Distfloor tociris-verify>=20.2.0,<21in
lockstep. v20.2.0 addsciris_crypto::transit_kex, the A/V per-hop
transit key (CC 5.3.3.5 E1) that runs the hybrid X25519 + ML-KEM-768 KEX
against the statictransit_kex_pubkeyspair persist stores on the
transport_destinationbinding since v56.0.0 (#1077). Persist stores the
pair; Edge runs the exchange. No persist API changes.ciris-build-sign
stays at the v20.1.0 prebuilt (no v20.2.0 GitHub release carries one).
Added — tooling: scripts/cargo_guard.sh (#1092)
- One build at a time on the box (
flock), an available-RAM floor (8 GB), and a
transient systemd user scope withMemoryMax(14 GB, no swap) so a runaway
cargo is OOM-killed inside its own scope instead of taking the session with
it (four sessions were lost that way during v55/v56). Every agent's cargo,
nextest and maturin call goes through it; exit 75/76 when the lock or the
floor is never met, 137 when the cap fires.
Changed — hard_case and detection onto the plane (#988 items 2, 3)
Two silos become rows on the attestation plane. Each table stays, as the
projection of its rows: written by the row's executor in the same
transaction, never by anything else, and rebuildable from the rows alone
(CC 2.4.2, CLM-silo-projection-rebuildable). Readers of the two tables
(list_hard_case_events, get_detection_events, LensCore's SQL) see the
same rows as before.
hard_case:{kind}:v1rows (CC 3.1.9.4,CLM-hard-case-on-base). Every
hard_caseevent is ascoresrow: attester = attested = this node's key,
cohort_scope = self, local tier, the event whole under thehard_case
member, the row id derived fromevent_id(so a re-record is still a
no-op). One pure plan,attestation_admit::plan_hard_case_row, decides it
for all three backends and all emitters: therecord_hard_casedoor, and
the four emitters that record inside their own transaction (the two trace
erasures, the community membership removal, the peer removal), which plan
the row inside that transaction so it commits with the act it records. The
generic local door refuseshard_case:dimensions (InvalidArgument): the
hard_case door is the only writer.- The row's signature is deferred, as on every local-tier row this node
attests (thetrace:complete:v1precedent): the node signs it as the
actor at the crossing. The crossing is held (below), so today the rows sit
at the local tier unsigned. Signing at write is not possible for the
in-transaction emitters (the hybrid signer is async; an erasure's counts
exist only inside its transaction). - A host must hold a node key before any erasure or removal is recorded.
The attester of every hard_case row is the node key (set_node_key_id),
and it must be held infederation_keys(register_self_federation_key).
Without one, every ACT that records a hard_case row is refused before it
mutates anything, with the new typed refusal
Error::HardCaseAttesterUnavailable(kind
federation_hard_case_attester_unavailable, the const
hard_case::refusal::ATTESTER_UNAVAILABLE; reasonnode_key_unsetor
node_key_not_held). Python raises the new
ciris_persist.NodeIdentityRequired(aPersistError). The acts: both
trace erasures (delete_traces_for_agent,
delete_traces_for_agent_id_hash; refused even when they would delete
nothing, store errorstore::Error::HardCaseAttesterUnavailable), the peer
removal (after its ownPeerNotFound/HardRemoveWithActiveAttestations
refusals), therecord_hard_casedoor itself, and the host-run watchers
that call it. I962 shows each refusal and that the same erasure SUCCEEDS
once the node key is held, on memory, sqlite and postgres. An
OBSERVATION riding on another row's admission or a cascade (a membership
change, a liveness transition, a recipient excluded, a fast-path takedown
applied) records nothing and proceeds (hard_case::record_observation), so
a node with no identity still applies a peer's removal and rotates its key.
AnEnginebuilt with a signer sets the node key; a host that never
registered its own key withregister_self_federation_keymust do so. - Unpurgeable, by the family table.
hard_case:joins
migration::ACCOUNTABILITY_RECORD_PREFIXES, the one list both purge doors
and the serve read consult (CIRISConstitution#170, the protective
direction). No door special-cases it. - HELD: hard_case replication. A
hard_case:*row does not enter the
federation tier:crossing::plan_enter_meshand the put door (which is also
the replicated apply) refuse it with the new
Error::HardCaseReplicationHeld(kind
federation_hard_case_replication_held, the const
hard_case::refusal::REPLICATION_HELD; PythonRuntimeError, the posture
ofNoActorSignature: a held door, not a caller fault). Local-tier rows are
never served, so no peer receives one. What unblocks it: CIRISConstitution#170
ruled (hard_case in CC 2.4.1.1's unpurgeable set), and a way to know which
peers carry the refusal (persist has no peer capability advertisement
today). Thenhard_case::check_hard_case_replication_heldbecomes a
per-peer serve filter. - The N4 proof carries both signed witnesses (CC 6.1.1):
witness::equivocation_hard_casenow puts the two conflicting corpus rows,
each with its Ed25519 and ML-DSA-65 halves, underdetail.witnesses. detection:{detector}:v1rows (CC 3.4.8,CLM-detection-on-base).
put_detection_eventwrites a local-tierscoresrow attested by the
detector's key, the detection whole under thedetectionmember (bytes in
base64), the row id derived fromdetection_id; one pure plan,
attestation_admit::plan_detection_row, holds V008's shape gates and the
dimension gate. Thedetection:family is the detector-only reserved
family, so the door now refuses asigning_key_idwhoseidentity_typeis
notlenscore_detector(behaviour change; the caller's hybrid verify of
canonical_bytesis unchanged). The detector signs the row as the actor at
the crossing; a federation-tierdetection:*row a detector emits itself
throughput_attestation(hybrid-signed, replicable) projects too, after
detection_row::check_detection_body, when it is the detection's own row
(the id derived from itsdetection_id). The envelope names the scored
trace asevidence_refs. The re-put verdict (same bytes
no-op, different bytesConflict) reads the held ROW, never the table.
calibration_bundlesstays configuration.- A detection's erasure goes through the purge door (lead ruling: a
signed row is immutable bytes; one tombstone mechanism). The row's
evidence_refsnames its trace, so a steward purge whose selection
reaches the trace derives the detection row ("scores over purged evidence
follow it"). While the purge tombstone stands, the projection row is the
V080 tombstone (linkage NULL,erased_at= the earliest-admitted
execution'sexecuted_at), written bypurge_put_executionin the same
transaction as the tombstone and rebuilt from it
(detection_row::project_detection_row_under);purge_finalizedeletes
the row and its projection row goes with it. I825 shows the window and the
finalize on memory, sqlite and postgres. - The DSAR erasure of a detection hard-deletes its signed row, and no
row remains. Both DSAR doors (delete_traces_for_agent_id_hash,
delete_traces_for_agent) delete each row-backed detection of an erased
trace in the erasure's transaction. The row is found by its own
detection.trace_id, so a row a steward purge already tombstoned goes
too. Never a rewrite. The projection row leaves with it by cascade.- Behaviour change: an erased row-backed detection leaves NO row in
detection_events. v56.0.0 kept a PII-free V080 remnant (linkage
NULL, analytics kept). No persist read door ever served it, so it was
reachable only by direct SQL on the host.detection_events_tombstoned
still counts the erased detection. The remnant was dropped rather than
carried on the erasure's row becauseconformity_payloadand
cohort_cellare opaque consumer JSON: inside an unpurgeable, signed
hard_caserow they could never be erased. - Pre-v57 rows. Before its transaction, each door moves onto the plane
the pre-v57 detections it can attest, so they are erased the same way. A
pre-v57 row it cannot attest (its detector key not held, or not a
lenscore_detector) keeps the in-place V080 NULLing.
detection_events_tombstonedcounts both kinds. - The Art. 17 header. The erasure writes one
purge_executionsheader
withlegal_basissteward_purge::ART17_ERASURE_BASIS
("art17_erasure"). Its claimant and executor are this node's key. It
has no cosignatures and is finalized at execution. Its notice is the
erasure's ownhard_case:trace_erasure:v1row, which carries the counts,
detection_rows_erasedand theselection_digest. One
attestation-plane tombstone per deleted row refuses the row's
re-admission: the TOMBSTONED gate, whichput_detection_eventasks too.
list_purge_executions_sincenever serves the header, on any backend. - I964 shows it on memory, sqlite and postgres, through both doors.
- Behaviour change: an erased row-backed detection leaves NO row in
- Memory holds detections. The memory backend's
put_detection_event/
get_detection_eventsreturnedNotImplemented/ empty; they now run the
same door and projection, so every witness runs on all three backends. - Migrations V194 (
hard_case_events) and V195 (detection_events),
sqlite and postgres: anattestation_idcolumn naming each projection
row's row,REFERENCES federation_attestations ON DELETE CASCADE(a row
that leaves takes its projection row), a unique index on it, and a partial
index over the pre-v57 rows that have none. Those legacy rows are rehomed
onto the plane by the first emitter after the upgrade, each asserted at the
silo row's own recorded instant (emitted_at/ts, never the rehome's
clock), idempotently (a second boot adds nothing; I963 on sqlite and
postgres, memory's rehome a declared no-op)
(rehome_legacy_hard_case_events, with the node key in hand;
rehome_legacy_detection_events, with each detector's key, a legacy row
whose detector key is not held or not alenscore_detectorstays and the
rebuild gate reports it). The rehome is lazy, and the gate shows it.
hard_case_eventsrows move at the first hard_case emitter after the
upgrade.detection_eventsrows move at the firstput_detection_event
or DSAR erasure. Until thenEngine::check_silo_projectionis RED. A
pre-v57 detection whose detector key is not held keeps it RED for good.
That is the operator-visible condition: register the detector's key and
emit once, or read the red as the record of a legacy row this node cannot
attest. Both tables join
steward_purge::ATTESTATION_PROJECTIONSas foreign-key cascades. - The gate:
silo_projection::check_projection_rebuild(and
Engine::check_silo_projection, the live predicate). It rebuilds the
projection from every held row through the same writer into a scratch
table of the live table's shape, renders both in the backend's own column
encoding (sqlitequote(), postgresrow_to_json(..)::text, memory serde)
and requires them byte-equal. I960 (hard_case) and I961 (detection) run it
on memory, sqlite and postgres, and tamper with one live column to show it
red. - Python surface: no signature changed.
list_hard_case_events,
put_detection_event,get_detection_eventsand the erasure doors keep
their types. New: the exception classNodeIdentityRequired
(PersistError), raised by an erasure or a peer removal on a node with no
held node key.put_detection_eventraisesValueError(the type of its
shape refusals) when the signer is not alenscore_detectoror is not
held.
Evidence — the register's hosted rows come home (#1091)
No library code changed. evidence/cc_impl.tsv goes from 225 to 271 rows
(46 added, none removed), each at the claim's own CC decimal under its claim
id, so the CC's next re-pin can graduate them.
- 27 claims whose only CC pointer was a row the CIRISServer manifest
hosted for persist at ciris-persist@v17.0.1: 28 rows. 24 publish the
symbol the server named, which still exists here. Three duties moved:
CLM-contributionscitesholding_claim.rs#holding_claim_row(the
retiredholds_bytes_attestation_envelope's successor, v55.0.0),
CLM-localcitesEngine::enter_mesh(replacedattestation_promote
in v39.0.0), andCLM-streamcitesseal_stream_chunkand
open_stream_chunk(stream_nonceis now aciris_cryptore-export). - 13 symbols persist already published at another decimal, now also at
the claim's own decimal (the nine the CC re-staged and the four it kept
established with a warning). The rows at the other decimals stay. CLM-transit-kex-binding(CC 3.3.6.2, #1077), which shipped in
v56.0.0 without a row: five rows,transit_kex_pair_for, the two
SignedTransportDestinationaccessors,transit_kex_pair_from_envelope
andverify_signed_transport_destination.
Changed — the community and roster doors share one plan (#934 step 2)
Five doors were written three times, once per backend, with byte-identical
gate sequences. Each is now one shared door in federation::community_admit
over a shared plan, and each backend keeps only a CommunityStore executor:
row mapping, SQL, the transaction, the wire index and the cfg(test) rival
hook. No behaviour changes.
| door | before (v56.0.0 scan) | shared door | plan | executor |
|---|---|---|---|---|
put_community |
identical x3 (9 gates) | put_community_planned |
plan_put_community |
store_planned_community |
apply_replicated_community |
gateless (delegated to the same per-backend body) | apply_replicated_community_planned |
plan_put_community |
store_planned_community |
put_community_membership_revocation |
identical x3 (4 gates) | put_community_membership_revocation_planned |
plan_put_community_membership_revocation |
store_planned_community_revocation |
put_community_membership_widening |
identical x3 (6 gates) | put_community_membership_widening_planned |
plan_put_community_membership_widening |
store_planned_community_widening |
put_community_membership_listing |
identical x3 (1 gate) | put_community_membership_listing_planned |
plan_put_community_membership_listing |
store_planned_community_listing |
-
The
store::parityscan now reports all five doors asshared, and
SHARED_DOORSholds them there. The per-backendput_community_at_door
bodies are gone. -
The occupied-id route splits into a decision and a write.
group_amendment::route_occupied_communityread and wrote in one call. It
is nowplan_occupied_community, which reads only and ends the plan, and
apply_occupied_community, which writes the supersede or the trust-root
chain through the shared supersede door after the plan returns. The order
of every read and write is the same as before. -
The held-row settle stays the door's first statement, ahead of the
plan. It reads the wire index, so the plans do not. I235 now reads the
settle from the four shared doors. -
Backend line counts, before and after this cut:
file before after delta src/store/memory.rs25,709 25,447 −262 src/store/sqlite.rs55,859 55,595 −264 src/store/postgres.rs52,727 52,482 −245 -
Each plan takes
PlanInstantsand reads no clock. Each gate reads the
instant it read before #934.store::parity'sPLAN_INSTANTSnow covers
the four community plans.plan call instant before #934 community check_geographic_community_admissionreceived_atclock (#1049 candidate: in-force location proofs) community check_infrastructure_record_admissionreceived_atclock, two calls deep (#1049 candidate: founders' node-bearing) community check_replicated_supersede_does_not_degradereceived_atclock, two calls deep (#1049 candidate) revocation reject_future_dated_community_revocationreceived_atclock (future-skew bound) revocation check_community_roster_authorityfold_ateffective_atrevocation check_infrastructure_founder_count_unchangedfold_ateffective_atwidening reject_future_dated_community_wideningreceived_atclock (future-skew bound) widening check_community_roster_authority,check_infrastructure_founders_not_node,check_infrastructure_founder_count_unchangedfold_ateffective_atlisting check_community_membership_listingreceived_atclock (future-skew bound) fold_atis the row's signed instant:effective_atfor the three roster
rows, and the record's head instant for a community record. No community
record gate readsfold_atyet.
Adopter-visible. Gate signatures now take an explicit instant. To keep
the previous behaviour, pass chrono::Utc::now().
community_dek::reject_future_dated_community_revocationand
reject_future_dated_community_wideningtakereceived_at.listing::reject_future_dated_community_listingand
listing::check_community_membership_listingtakereceived_at.admission::check_infrastructure_record_admission,
check_replicated_supersede_does_not_degradeand
check_infrastructure_community_conformancetakeat. The supersede door
(supersede_group_row) still passes the clock.
Known gap — the community plane after #934 step 2
- Clock reads two or more calls deep.
check_trust_root_at_door
(throughcheck_trust_root_community_admission) and the trust-root chain
apply inapply_occupied_communitystill read the clock inside
canonical_community. They were not re-plumbed in this step. - The occupied-id write still re-asks after a write. A trust-root chain
apply that finds the id empty re-runs the founding-signer check before the
insert, insideapply_occupied_community, as it did before. - Non-row reads. The settle reads the wire index before the plan. The
plans read the directory's community, key and location-proof rows only.