Skip to content

ciris-persist v60.0.0

Choose a tag to compare

@github-actions github-actions released this 26 Oct 21:35
· 222 commits to main since this release
8dec0de

[60.0.0] - 2026-10-26

An architecture cut (docs/V60_TASK_PLAN.md): signed attestations are the only
state (CEWPOS). The cirisnode classes and Goals become scores rows, the
organisation planes become affiliation rows, the key-record, consent and
sweep doors decide in one shared plan each, and the in-process memory backend
is gone. No feature outside the issues named here. Sections 1–14 are in
slice order (S1–S6, then S0's fixes and tooling) and §15 is the advisories;
§16 is the whole adoption list for CIRISEdge, CIRISServer and
CIRISAgent
, derived from git diff v59.1.0.. over the .pyi, src/ffi,
the error enums and the migrations. Migrations V202–V205; witnesses from
I1161; supersets.rs EXPECTED 313 → 342.

Changed — 1. the cirisnode classes are scores rows (S1; CIRISPersist#988 item 1, #803, #854)

Signed attestations are the only state (CEWPOS). Every cirisnode class is a
dimension CC 3.1.9 registers, so each is now a hybrid-signed scores row on
the attestation plane, signed by its contributor's federation key, admitted by
the plane's own door and replicated like any row. The cirisnode.* typed
tables that held self-signed, Ed25519-only, never-replicated copies are gone.

class row admission
Contribution (CC 2.1) contribution:{subject_kind}:v1 the plane's door
vote vote:{contribution_id}:v1, attested to the Contribution's author the plane's door
moderation event moderation:{allegation_type}:v1, attested to and naming the accused the moderation gate (moderate duty-holders)
slashing slashing:{outcome}:v1, naming the accused as subject the plane's door
reconsideration filing / ruling reconsideration:{grounds}:v1 (a ruling carries outcome) the v59 subject-standing rule
credits / expertise balance credits:{domain}:{language}:{subject}:v1 / expertise:{domain}:{language}:v1, this node → the contributor the plane's door; PROJECTED into the ledgers
takedown notice takedown_notice:{basis}:v1, subject_kind: takedown_notice, score -1 NEW: the CC 4.5.5 duty-holder gate
  • The ledgers are projections (CLM-silo-projection-rebuildable,
    federation::ledger_projection). credits_ledger / expertise_ledger hold
    the fold of THIS node's balance rows (a peer's balance about the same
    contributor is held, never projected), the latest (asserted_at, attestation_id) per key, written by the put door's executor in the row's
    transaction on sqlite and postgres. Each ledger row is a pure
    function of one row: last_updated_at and created_at are both its signed
    instant, and it carries the row's attestation_id (new column). New
    SiloProjection::CreditsLedger / ExpertiseLedger; a pre-V203 balance
    (no attestation_id) is a red in check_projection_rebuild until the
    rehome (Engine::cirisnode_rehome_ledgers, run first by every balance
    write) re-emits it as this node's row.
  • The takedown notice is a row (#803, CLM-takedown-notice-row). The put
    door admits a takedown_notice:{basis}:v1 row only from a duty-holder over
    the content as this node holds it (takedown_authority, the decision the
    tombstone door re-asks); the steward_purge leaf stays held shut (its
    cosigned door is not built). REGISTERED_FAMILIES_HELD_SHUT narrows from
    takedown_notice: to takedown_notice:steward_purge:. The tombstone door
    takes the notice row (TakedownNotice::of_row), verifies its hybrid
    signature against the signer's registered keys (an unknown signer is the
    retryable federation_attester_key_unknown), holds the row, and its header
    carries the row whole; TakedownReport.notice_attestation_id names it. A
    notice row is about its content, never a binding of it: content_binding
    skips the family. LegalBasis takes CC 3.3.2's wire strings: dmca512,
    dsa_article16 (dmca_512 / dsa_article_16 no longer decode); the
    pinned-divergence test is deleted.
  • Compat read, one release (v60.x, removed in v61): a v59 peer's purge
    bundle still carries its notice as the retired takedown_notice
    CONTRIBUTION. apply_replicated_takedown still applies it (Ed25519
    self-signature, as v59 verified it; basis read in CC's spelling; CC 4.5.5
    re-asked here). The LOCAL door refuses the Contribution
    (takedown_unauthorised, "retired").
  • #854, one subject gate. SubjectGate and
    validate_subject_key_ids_at are removed; validate_subject_key_ids is the
    rule at every door, a replicated row included, so an uppercase subject id
    (the raw base64 pubkey the retired Contributions carried) is refused at
    ingest. The canonical-spelling clauses run before the general uppercase one
    so each refusal names its defect.
  • Retired, refused by name: a key_grant Contribution
    (cirisnode_key_grant_retired; the key-grant plane, key_grant:{axis}:v1,
    is the one door for a wrapped key), and a v59 typed envelope offered at a
    cirisnode door (it carries signature / a contributor id:
    cirisnode_typed_object_retired). New cirisnode::Error::Retired (its
    kind() is the token) and cirisnode::Error::Federation (a plane refusal,
    its kind() the plane's).
  • V203 (sqlite + postgres twins): drops contributions, votes,
    moderation_events, slashing_attestations, reconsideration_requests,
    reconsideration_attestations and V056's two watch tables
    (cirisnode_consent_sla_watch, cirisnode_revocation_promotion_watch,
    nothing read them): 8 tables. Keeps the ledgers (with attestation_id),
    scheduled_takedown_actions, federation_delivery_attestations and
    promotion_attestations; their foreign keys into the dropped table go
    (sqlite: staged and re-created under the final name; postgres: DROP … CASCADE drops only the constraints) and the row-naming columns become
    TEXT on postgres. Rewrites purge_executions.legal_basis to CC's
    spelling. The dropped rows were self-signed by keys persist cannot re-sign;
    nothing outside persist wrote these tables.
  • Kept, with a row rule: a promotion record's targets are rows held here
    on the target kind's family (all or none; the is_canonical flip is gone
    with the tables); a delivery attestation's announcement is a held
    contribution:federation_announcement:v1 row.

Python surface (method names kept; each write plans the row and signs it
with the engine's own composed hybrid signer):

method v59 v60
cirisnode_put_contribution (envelope_json) -> None (body_json) -> str (the row id; a takedown_notice subject kind files the notice row)
cirisnode_cast_vote (envelope_json) -> None (body_json) -> str ({contribution_id, score, detail?, rationale?})
cirisnode_update_credits_ledger (update_json) -> None (update_json) -> str
cirisnode_update_expertise_ledger (update_json) -> None (update_json) -> str
cirisnode_put_moderation_event (event_json) -> None (body_json) -> str ({target_contributor, allegation_type, community_id, payload})
cirisnode_put_slashing_attestation (att_json) -> None (body_json) -> str ({moderation_id, outcome, payload})
cirisnode_put_reconsideration_request (req_json) -> None (body_json) -> str ({slashing_id, grounds, payload})
cirisnode_put_reconsideration_attestation (att_json) -> None (body_json) -> str ({request_id, outcome, payload})
cirisnode_list_contributions (filter_json, cursor_json, limit) (author_id, filter_json=None, cursor_json=None, limit=100); items are rows, the cursor an AttestationCursor
cirisnode_list_votes (filter_json, cursor_json, limit) (contribution_id, voter_id=None, cursor_json=None, limit=100)
cirisnode_list_takedowns_for_filtered_json filter optional filter_json must name claimant_key_id; items are notice rows
cirisnode_process_takedown_admission_json (contribution_json) (notice_attestation_id); the report adds notice_attestation_id
cirisnode_get_credits_ledger / _expertise_ledger — the entry adds attestation_id; created_at is the row's instant
cirisnode_put_promotion_attestation — target_ids are held row ids
cirisnode_put_delivery_attestation — the announcement must be a held announcement row
cirisnode_rehome_ledgers — NEW () -> int
cirisnode_check_ledger_projection — NEW (projection) -> str (credits_ledger / expertise_ledger)

Removed: cirisnode_list_takedowns_for_json, cirisnode_list_key_grants_for_json,
cirisnode_list_key_grants_for_content_json,
cirisnode_list_key_grants_for_filtered_json,
cirisnode_list_key_grants_for_scope_epoch_json,
cirisnode_put_key_grant_json, cirisnode_retire_key_grants_json.
Unchanged: cirisnode_routable_contributors, cirisnode_read_vote_weight,
cirisnode_list_delivery_attestations, cirisnode_count_delivery_attestations.

Rust surface: NodeCoreService keeps the ledger reads, the promotion
record and the delivery attestations, and adds legacy_ledger_rows; the
typed writes and list reads, put_key_grant, retire_key_grants and the
media reads are gone, with ContributionEnvelope, VoteEnvelope,
ModerationEvent, SlashingAttestation, ReconsiderationRequest,
ReconsiderationAttestation, the old ContributionsFilter, VotesFilter,
ListCursor, the list pages, TakedownFilter, KeyGrantFilter,
KeyGrantPayload and its types, RetireKeyGrantsOutcome,
takedown_notice_from_contribution, check_moderation_or_reject,
check_takedown_or_reject, payload_target_descriptor,
Engine::list_takedowns_for / list_key_grants_for and the sqlite
backend's set_admission_gate (the plane's gate is the gate). New:
cirisnode::rows (the planners, decode_input, the reads) and the
Engine::cirisnode_* doors. TakedownNoticePayload drops claimant_key_id
and asserted_at (the row's) and refuses unknown fields;
takedown_tombstone::takedown_terms is async (directory, notice, origin).
What Edge, Server and Agent adapt: §16.

  • Witnesses: I1166 (sqlite, postgres) the ledgers are this node's
    rows, arrival-independent, a peer's balance never projected, byte-equal
    rebuilds; I1167 (sqlite, postgres) each class admits as a row
    and the moderation / reconsideration gates refuse the stranger;
    I1168 the retired objects refused by name; I1169 (sqlite, postgres)
    a legacy balance is red until superseded; I1170 (sqlite and postgres
    engines; and sqlite, postgres for the binding) the notice row:
    admitted for the subject, refused for a stranger, steward_purge held
    shut, drives the tombstone door, never binds its content; the balance
    doors, the rehome and the delivery attestation's row rule; I1171
    (sqlite, postgres) the one-release compat read; I1172 one
    subject gate at ingest; I1173 (sqlite, postgres) V203 over an
    upgrading node's populated store. The takedown witnesses (I786–I799, I772,
    I778, I801) now build notice rows. Python:
    tests/python/test_cirisnode_rows_surface.py (replaces the retired
    test_transit_key_grant_surface.py; the transit grant is the key-grant
    plane's, test_key_grant_rows_surface.py).
  • Mutation scope scripts/mutants/scope-s1-cirisnode.txt: 215 mutants in four
    shards, 175 caught, 40 unviable, 0 missed, nothing allow-listed (two rounds:
    the first left 43 survivors, each a check no witness distinguished; the
    witnesses above kill them all). I1107 and I1173 are not in its tests:
    (their sites, the held-shut table and V203's SQL, are not mutation sites).
  • Evidence: CLM-takedown-notice-row (3.1.1) at the notice row's admission,
    the door's row reader and the planner; CLM-silo-projection-rebuildable
    (2.4.2) at the ledger projection.

Changed — 2. a Goal is a signed goal:{scale}:v1 row (S2; CIRISPersist#988 item 4, CC 3.1.9.7)

Signed attestations are the only state. A Goal was a typed value written by
put_goal into goals, never signed by its declarer and never replicated by
persist. It is now the declarer's scores row on goal:{scale}:v1, and its
lifecycle is the structural verbs:

act row
declare scores on goal:{scale}:v1; goal_id is its attestation_id
amend the declarer's supersedes naming the goal's current head, carrying the whole new body
retire the declarer's withdraws naming the declaration; retired_at is its signed instant
"never mine" the declarer's recants naming the declaration; the goal leaves the projection
  • The scale. CC 3.1.9.7's open {scale} vocabulary: the v59 GoalScope
    maps single_declarer → self, cohort → cohort (the cohort id as the
    envelope member cohort_id), federation → federation; any other
    lowercase vocabulary token admits as the registry admits it.
  • M-1 is the family's admission gate (goal::check_goal_row_admission,
    run by the plane's put door for local and replicated rows): a goal
    declaration or amendment whose envelope lacks a well-formed
    meta_goal_alignment (a dimension in the seven, a non-empty rationale, an
    optional well-formed deliberation_ref, nothing else) is
    federation_malformed_row with reason goal_meta_alignment_malformed;
    any other body defect (dimension not a clean goal:{scale}:v1 leaf, empty
    goal_text, cohort_id present iff not cohort, score outside [-1, 1])
    is goal_body_malformed. A supersedes naming a held goal row must be
    its declarer's and carry the body (federation_invalid_argument
    otherwise). Three reasons appended to malformed_row::ALL:
    goal_meta_alignment_malformed, goal_body_malformed,
    goal_typed_object_retired.
  • A negative score is the declarer's stance: positive pursues the stated
    state of affairs, negative declares it a state the declarer works against
    (an avoidance goal); the magnitude is the commitment. It is still a
    declaration; it never retires or disowns anything.
  • goals is a projection (goal_projection, SiloProjection::Goals): one
    row per declaration held (any declarer's), the head's body, written by the
    attestation executor in the row's transaction on every backend and
    byte-equal to its from-rows rebuild. Rows arriving out of order fold the
    same (each row of a chain refolds its declaration). V204 (both
    dialects) rebuilds the table: scope_kind → scale (single_declarer
    rewritten self; the closed scope CHECK dropped, the cohort discriminant
    kept), goal_id TEXT on postgres, new attestation_id (the head row,
    CASCADE) and score, inserted_at dropped (a write clock is not a
    function of the rows). A pre-V204 goal keeps attestation_id NULL, is not
    returned by the reads, and is red in the rebuild until
    Engine::rehome_legacy_goals moves it: this node's own goal is re-signed as
    a row (its retirement re-signed as a withdraws); another declarer's is
    removed (persist cannot sign for it, and the typed row never carried its
    signature); one the gate would refuse is removed.
  • No replication compat read. Persist never replicated a Goal (no
    replication kind carried one); CIRISEdge carried its own GoalDeclaration /
    GoalRetirement messages wrapping persist's Goal, which Edge retires
    with this release. A goal now crosses as the row.

Python surface:

method v59 v60
cirisnode_put_goal_json (goal_json) -> None (a typed Goal) (body_json) -> str ({goal_text, scale, cohort_id?, meta_goal_alignment, score?}; returns the goal id; a v59 Goal raises goal_typed_object_retired)
cirisnode_retire_goal_json (goal_id, retired_at_rfc3339) -> None (goal_id) -> str (the withdraws row id; the instant is the row's)
cirisnode_get_goal_json goal_id a UUID goal_id the declaration's row id; the JSON carries scale / cohort_id (not scope), score, attestation_id
cirisnode_list_goals_json filter scope_kind filter scale; unknown keys refused
cirisnode_amend_goal_json — NEW (goal_id, body_json) -> str
cirisnode_recant_goal_json — NEW (goal_id) -> str
cirisnode_rehome_goals — NEW () -> str ({"rehomed": n, "dropped": n})
cirisnode_check_goal_projection — NEW () -> str ({"live": n, "rebuilt": n})

Rust surface (clean break): FederationDirectory::put_goal and
retire_goal are gone; get_goal takes &str; legacy_goals /
delete_legacy_goal are added. Goal::new is gone and Goal is the
projection's read type (goal_id: String, scope, score,
attestation_id); GoalScope is a validated {scale, cohort_id}
(GoalScope::new); GoalsFilter::scope_kind is scale. New: GoalInput,
LegacyGoal, GoalRehome, goal::{parse_goal_body, check_goal_row_admission, plan_goal_row, plan_goal_amendment_row, plan_goal_retraction_row, decode_goal_input}, goal_projection, and Engine::{put_goal, amend_goal, retire_goal, recant_goal, rehome_legacy_goals}. What Edge adapts: §16.

  • Witnesses: I1174–I1176 (unit) the scales, the M-1 rule, the four
    planners and the retired typed body refused by name; I1177 (unit) the
    fold; I1178 (sqlite, postgres) the projection through the put
    door, out of order included; I1179 (sqlite, postgres) the M-1
    gate at the door; I1180 (sqlite and postgres engines) the four Engine
    doors, the declarer rule and the rehome; I1181 (sqlite, postgres) V204
    over an upgrading store; I1182 (sqlite, postgres)
    approach:{goal_id} binds the goal's row id. Python:
    tests/python/test_goal_rows_surface.py.
  • Mutation scope scripts/mutants/scope-s2-goals.txt: 155 mutants in three
    shards, 127 caught, 28 unviable, 0 missed, nothing allow-listed (two rounds:
    the first left six survivors; four were witness gaps, now witnessed, and two
    were equivalent forms of a redundant test, now removed). Weekly, three
    shards, in mutants.yml.
  • Evidence: CLM-nsproc-meta-goal-alignment-goal-scale (3.1.9.7) moves from
    the retired Goal::new to check_goal_row_admission;
    CLM-silo-projection-rebuildable (2.4.2) at goal_projection::fold_goal.

Changed — 3. the key-record write doors decide in one plan (S3; CIRISPersist#934 step 3)

Every door that writes federation_keys (and with it the key's
key:record:v1 row) used to run its own copy of its decision on each of the
three backends. The decision now lives once, in
key_plane::plan_key_record_write, one planner per door kind
(KeyRecordWrite: Put, SeedGenesis, ScrubUpgrade,
SupersedeCanonical, Rebind, InvalidReplace, Reanchor, AttachPqc,
RehydrateScrubs); it calls the gates that were already shared
(admit_key_record, the four conferral gates, check_node_identity_*,
prepare_rebind / prepare_invalid_replace, verify_canonical_supersede,
verify_bundle_quorum, check_key_pqc_attachment) in the order the copies
ran them. Each backend keeps ONE executor,
KeyPlaneStore::store_planned_key_record(PlannedKeyRecord): the SQL, the row
mapping and the transaction, the write's atomic re-assertion as its WHERE,
and the key's row derived from the record as stored through the shared
key_record_row_step. The replicated apply is one shared body too
(apply_replicated_key_record_planned).

The parity scan (store::parity) now reports put_public_key,
adopt_scrub_upgrade, store_rebound_key_record,
store_invalid_replaced_key_record, adopt_genesis_reanchor,
attach_key_pqc_signature and apply_replicated_key_record as shared
(they were identical x3 or, for the two that delegated to inherent methods,
invisible to it), and SHARED_DOORS holds them there.

Behaviour that changed (each was a backend divergence):

  • The genesis seed stores the whole scrub set: both SQL backends' seed
    INSERT left additional_scrubs out (the drop RehydrateScrubs repairs,
    #995 row 2).
  • consent_role is normalized to its wire form before the hash on every
    backend: a record offered with the stored token
    'unregistered' now hashes as the None it reads back as.
  • A sqlite insert that loses a race to an identical row is settled, and to a
    differing row is Conflict (it was a primary-key Backend error); a
    rewrite whose guard matched nothing now rolls back on every backend (the
    re-anchor committed its row step before refusing).
  • The genesis re-anchor's anti-rollback compares at the substrate's
    resolution.
    Postgres holds the anchored valid_from in microseconds, so
    a bundle record with a nanosecond instant (the v2 ceremony's canonical is
    …22.147317128Z) read as 128 ns newer than itself, and re-offering it to
    adopt_genesis_reanchor re-wrote the row instead of refusing the
    rollback. Both sides are now compared at the substrate's signed-instant
    resolution (truncate_to_substrate_resolution; I1189). The bake door's
    own pre-check (bake_assembled_genesis, serve nodes) had the same shape
    and compares the same way: a held anchor carrying the bundle record's
    nanosecond instant was read as older on postgres (I1191, sqlite and
    postgres, red on postgres before the fix).
  • The canonical supersede settles a byte-identical re-offer before any
    verify (the #672 rule), instead of after the policy check, where an
    identical record could never arrive (verify_canonical_supersede refuses
    it first).

Rust surface (clean break): the inherent SqliteBackend /
PostgresBackend::{adopt_scrub_upgrade, supersede_canonical_record, apply_replicated_key_record} are gone; the FederationDirectory methods of the same names are the doors
(bring the trait into scope). No FederationDirectory method changed, no
Python signature changed, no migration.

  • Witnesses: I1183 (the insert door), I1184 (the genesis seed),
    I1185 (the scrub upgrade), I1186 (the replicated apply), I1187
    (each executor's atomic re-assertion, every write shape), all on sqlite
    and postgres; I1189 (sqlite, postgres) the re-anchor's
    own refusals over the real v2 ceremony; I1190 (sqlite,
    postgres) the supersede's settle, pubkey-change and policy refusals;
    I1191 (sqlite, postgres) the bake door's anti-rollback at resolution;
    I1188 (unit) the row step and the lost-race wording.
  • Mutation scope scripts/mutants/scope-s3-key-plane.txt: 129 mutants in
    three shards, 101 caught, 28 unviable, 0 missed, nothing allow-listed (two
    rounds: the first, 136 mutants, left twelve survivors; ten were witness
    gaps, now I1187's anchored-version and rehydrate legs, I1189 (the
    re-anchor's predicate and both SQL doors), I1190 (the supersede's three),
    and the role-bearing leg of I1183; two were the row step's self-scrub guard on
    sqlite and postgres, an equivalent branch, now removed on all three
    backends). Weekly, three shards, in mutants.yml.
  • Evidence: CLM-planes-as-rows (3.1.3) at key_plane::plan_key_record_write.

Changed — 4. the consent and sweep planes decide in one function each (S4; CIRISPersist#934 step 4, #1105)

Fourteen doors were written once per backend: the two consent writes
(set_consent_role, grant_trust), the five consent reads
(list_consent_peers, list_consent_peers_for,
list_live_consent_grants_by / _for, list_consent_revocations) and the
sweeps' selections (the PQC sweep's list_hybrid_pending_keys /
_attestations, the reaper's list_expired_attestation_ids,
evict_known_wire_hashes, the room retention leg
stream_dek_evict_room_chunks, and the two observation prunes). Each now
decides in one place:

  • Writes — consent_admit::plan_set_consent_role (the CC 3.4.7.2 token
    gate, the wire normal form) and consent_admit::plan_grant_trust (the #721
    authority gate first, then the shape, then the row), run by
    set_consent_role_planned / grant_trust_planned, which also word the
    no-row refusal. validate_trust_grant moved from store::memory to
    consent_admit.
  • Reads — one fold each over a typed read (ConsentRead::{Node, For}):
    the fold sorts and deduplicates the peers, filters the live grants to
    consent:replication:v1 and orders them, applies the subject-side
    revocation rule (is_subject_side_revocation); the executor returns the
    projection rows or a candidate superset and decides nothing.
  • Sweeps — one selection each in sweep_plan
    (HybridPendingSelection, ExpiredSelection, known_hash_eviction,
    RoomChunkEviction::selects) and retention::plan_observation_prune;
    each backend keeps the delete (or the read) as its executor. The room leg's
    executor runs the shared predicate inside its delete transaction, so the
    selection and the delete stay one atomic step.
  • The executors are three new crate traits, compared by the parity scan like
    any door: ConsentStore, SweepStore, RoomEvictionStore. The key-grant
    doors (key_grant_held_owner, project_key_grant_row) already ran one
    shared body; they now read as shared too.
  • The parity scan reports all of them shared (they were identical x3 or
    gateless) and SHARED_DOORS holds them; a new SHARED_SELECTIONS list
    holds the read folds and sweep selections to the opposite rule from a plan:
    a selection runs NO gate, and some door runs it.

#1105 — the family-scoped key-plane holdings read (Edge ask).
FederationDirectory::list_key_plane_wire_hashes_since(family, after, limit):
the Attestation wire hashes of the scores rows whose dimension is one of
key_plane::HOLDINGS_FAMILIES (key:record:v1, key:revocation:v1,
identity:occurrence:v1, identity:occurrence_revocation:v1), paged and
cursored on the hash exactly like list_wire_hashes_since. One shared read
plan (key_plane::plan_key_plane_holdings, the family gate refusing any
other dimension as InvalidArgument, never an empty set) and one thin
executor per backend (KeyPlaneStore::read_key_plane_wire_hashes). Routed
through the directory capsule as DirectoryOp::ListKeyPlaneWireHashesSince
(appended; DIRECTORY_ABI_VERSION unchanged; a refusal crosses the capsule
as Backend text, like every op). Not on the Python surface.

Behaviour that changed (each was a backend divergence or an unordered
tie):

  • list_hybrid_pending_keys / _attestations with a zero or negative
    limit return nothing on every backend; sqlite returned EVERY pending
    row for -1 (LIMIT -1 is no limit) and postgres refused it. Ties on the
    order instant come by id.
  • list_expired_attestation_ids orders ties on expires_at by id, so the
    reaper's OFFSET paging is a total order (it could skip or repeat rows that
    expired together); an offset past i64::MAX reads nothing (sqlite and
    postgres paged from the start).
  • list_live_consent_grants_by / _for and list_consent_revocations order
    ties on asserted_at by id, descending.
  • grant_trust stamps trusted_at with the door's one clock read on every
    backend (postgres used the database's NOW()).
  • Postgres's room leg takes the minter's epoch rows FOR UPDATE in its
    transaction, so two concurrent sweeps of one minter do not both read an
    epoch as not yet evicted.
  • A postgres grant_trust whose row trips a V020 CHECK now reads as
    Backend (it was re-worded InvalidArgument); the plan refuses both
    shapes the CHECKs guard before the write, so no admitted grant reaches it.
  • The live-grant read counters are recorded by the shared fold, once per
    read, under the executing backend's label (I551 learns the shape).

Rust surface: FederationDirectory::list_key_plane_wire_hashes_since
(new, default Unsupported); the directory capsule op above. No other
FederationDirectory method changed, no Python signature changed, no
migration.

  • Witnesses: I1192 (the consent-role door), I1193 (the trust-grant
    door), I1194 (the consent-peer and live-grant folds), I1195 (the
    consent-revocation fold), I1196 (the PQC sweep's selection), I1197
    (the reaper's selection), I1198 (the known-hash eviction), I1201
    (the #1105 holdings read), all on sqlite and postgres; I1199
    (unit) the room leg's predicate; I1200 (unit, and postgres) the
    observation prune plan and its executor; I1196 / I1197 also unit.
  • Mutation scope scripts/mutants/scope-s4-consent-sweep.txt: 302 mutants in
    six shards, 256 caught, 46 unviable, 0 missed, nothing allow-listed. Three
    rounds: the first (304 mutants) left nineteen survivors. Fifteen were
    witness gaps: a subject's rule-2 withdraws (I1195), the strict expiry
    boundary (I1197), the attestation plane of the PQC sweep (I1196), and the
    key-grant doors, whose witnesses the filter did not name (now I591 / I596,
    I61, I800, I743). Two were the postgres trust executor's CHECK-name
    mapping, unreachable behind the plan's shape validation, now removed. The
    second round left the projection door (I743 added); the third, shard 1
    again, was clean. Weekly, six shards, in mutants.yml.
  • Evidence: CLM-planes-as-rows (3.1.3) at
    key_plane::list_key_plane_wire_hashes_planned; CLM-nsproc-tier-consent
    (3.3.1) at consent_admit::consent_revocations_planned.
  • Size: the backend files did not shrink at S4 (sqlite +31, postgres +34
    lines): what each backend kept is the SQL and row mapping, which is
    the executor's, and what left (the decisions) was small. The decisions
    are now written once.

Removed — 5. the in-process memory backend; every genesis door runs on each backend alone (S5; CIRISPersist#1002)

Every test, bench and witness that ran on MemoryBackend runs on SQLite. The
memory backend answered with its own in-process re-implementation of every
door: real SQL, a real schema and real transactions are where the reds it
could not produce come from, and each door it carried was a third copy to keep
in parity.

  • Removed (Rust, clean break): store::MemoryBackend and src/store/memory.rs
    (25,604 lines at v59.1.0; 11,949 when deleted, its tests moved first). No alias.
  • Added (Rust): store::test_backend::test_backend() (feature sqlite) —
    a fresh, fully migrated in-memory SqliteBackend, cloned from one migrated
    template per process with the SQLite backup API (7.1 ms a clone against
    248 ms for open_in_memory() + every migration; MemoryBackend::new was
    81 µs). Three doc-hidden fixtures stay public for the integration tests and
    benches (add_public_key, add_pqc_public_key, snapshot_events); every
    other below-the-door seam is cfg(test), so no consumer build links a
    writer that skips the doors.
  • The genesis scratch is per backend (steward ruling 2026-10-25: parity
    even for genesis). The doors that replayed a bundle on a memory node
    (verify_ceremony_outputs, the import door, CeremonyState::finish,
    AmendState::{plan, finish}) take a &dyn genesis::scratch::GenesisScratch
    and run on what the backend provisions: SQLite a migrated in-memory clone,
    PostgreSQL a throwaway database in the live cluster created from the
    configured DSN (ciris_genesis_scratch_{unix_secs}_{pid}_{hex}), migrated, and dropped
    WITH (FORCE) when the replay ends — on success, on a refusal, and on drop
    (a panic). The configured role needs CREATEDB for these doors. A rolled-back
    transaction was not used: the replay runs a dozen doors, each on its own
    connection. A scratch a killed process left behind (its name carries its creation
    instant, ciris_genesis_scratch_{unix_secs}_{pid}_{hex}) is dropped by the
    next provisioning once older than an hour
    (GENESIS_SCRATCH_REAP_AFTER_SECS); a name the reaper cannot read is never
    dropped. Every door compiles and runs with postgres alone and with
    sqlite alone. Signature changes (clean break):
    verify_ceremony_outputs(bundle_json, scratch),
    install_genesis_bundle_roster(dir, scratch, bundle),
    CeremonyState::finish(scratch), AmendState::plan(pinned, rows, scratch),
    AmendState::finish(scratch); Engine::install_genesis_bundle_roster and
    PyEngine.install_genesis_bundle_roster_json pass their own backend. New
    refusal CeremonyOutputsRefusal::ScratchDirectory
    (ceremony_scratch_directory): the scratch could not be provisioned or
    released. New store::postgres::GENESIS_SCRATCH_DB_PREFIX. Witnesses
    I1324–I1331.
  • Python: no Python surface named or constructed the memory backend, so
    nothing is renamed. PyEngine.install_genesis_bundle_roster_json keeps its
    signature and runs on every backend the build carries. No .pyi change.
  • Telemetry: the backend label set is {sqlite, postgres}
    (observe::StoreBackend::Memory and the registry member are gone;
    telemetry/catalog.json re-rendered). A dashboard keyed on
    backend="memory" sees no series.
  • Benches: ingest_pipeline and queue run on the test backend and need
    sqlite (they declared postgres and never used it). Their numbers now
    include SQL writes; the baselines move.
  • Tests: the memory arm of every witness is gone (its duplicates of a
    sqlite leg dropped; the tests only memory carried moved to
    src/store/backend_suite.rs); store::parity compares two backends; the
    from-disk gates and rules/ read two backend files. A build with no backend
    feature allows dead code crate-wide (every caller of the shared plans is a
    backend). The server and server-pipeline unit tests need sqlite.
  • Folded: #720 — the lifecycle filter is one plan-emitted predicate
    (attestation_query::lifecycle_predicate, I1320) instead of four SQL
    copies and a memory fold; #691 — store::tree_read binds the parity and
    schema-parity verdicts to the tree they read (I1321); #787 — every
    DirectoryOp has an OpsDirectory proxy or a stated reason (I1322).

Changed — 6. CC v1.0-rc9 vendored at the tag; the policy table is runtime data; the corridor runs at admission (S6v)

CIRISConstitution tagged v1.0-rc9 on 2026-10-21 (tag object a1c2bea5,
commit 5d50c6bb160ecbee99a66e1700597c029846e6b6, CIRISConstitution#182).
Persist vendors it at the tag: scripts/check_vendored_cc.sh v1.0-rc9 passes
for all twelve vendored files.

For Edge: the namespace registry diff (scripts/manifest_diff.py 32512f52, rc8.1 → rc9; registry _meta.registry_sha256 22b029cf… →
feeb06a0…, source 73aa281f… → afd1948e…):

families 174 → 176 (net +2)
added identity:occurrence_revocation (CC 3.1.3, -1 only, reserved: signed by the identity or a live occurrence of it), transport:canonical_rebind (CC 3.1.3, positive-only, reserved: substrate-self-report)
removed none
changed objection:{state}: the {state} segment is now an OPEN vocabulary with the listed values proposal, assent, raised (it named no values); description prose
_meta cc_version 1.0-rc8.1 → 1.0-rc9; n_families 174 → 176; per_component.persist 22 → 24

Both added families are persist's own v59 mints (CIRISPersist#1104, the
legacy occurrence revocation's carrier, and #1106, the holders' quorum
re-bind), now registered under CC 3.1.7 R2(a); objection:proposal:v1 /
objection:assent:v1 (the self-roster door's rows, #1099) are now in CC's
listed vocabulary. No admission behaviour changed for a conformant row: the
two carriers already carried the scores their polarity classes now state
(-1, +1). The match vectors grow 1147 → 1159 (six per new family; the
objection sample is proposal). The binds oracle is regenerated at the tag
commit.

pin (registry.rs) rc8.1 rc9
VENDORED_CC_VERSION 1.0-rc8.1 1.0-rc9
VENDORED_CC_COMMIT 157039d3… 5d50c6bb…
VENDORED_SOURCE_SHA256 73aa281f… afd1948e…
VENDORED_REGISTRY_SHA256 22b029cf… feeb06a0…
VENDORED_N_FAMILIES 174 176
VENDORED_REGISTRY_FILE_SHA256 44d4d480… be0b1c86…
VENDORED_VECTORS_FILE_SHA256 c1a989a7… a1a6ce5f…
namespace_match_binds.json (rc8.1 oracle) 86a9980b… (1159 vectors)
  • Four fixtures are runtime manifests now (src/federation/namespace/,
    beside the registry, pinned in the new namespace::cc_manifests):
    governance_policy.json (CC 4.5.13, policy_sha256 b7d2370d…),
    governance_policy_vectors.json (732 vectors), csd4_chain.json,
    topology_vocabulary.json, each byte-identical to git show v1.0-rc9:manifests/<file> (they were copied from the untagged rc9 branch
    in v59 / v60 S0; the bytes did not change). Readers repointed:
    scripts/csd4_coverage.py, the two_path replay, the reports workflow.
    CIRISServer's catalogue (traces.json, requirements.yaml, PIN.md,
    topology_vocabulary.pin) stays under tests/fixtures/csd4/: no CC tag
    carries it. The six formal/self_roster traces stay test fixtures, pinned
    at the tag.
  • The policy table is read at runtime (federation::governance_policy):
    PolicyTable parses the compiled-in table once; policy_of builds a row as
    a TwoPathPolicy (ENACT strictly over its fraction; CANCEL / FLOOR at
    least; the self rows' "strict majority of the others" and "one_voter";
    cancel_over; at_window; the corridor's count minimum, checked against the
    table's min_count; the window checked against its class), refusing any form
    it does not read; check_corridor is CC's own table check
    (tools/build_governance_policy.py at the tag) over
    TwoPathPolicy::check_corridor: a reverse-path community row at bases 6, 7,
    10 and the expected-active base 344, a forward-only row's enact and window
    floors at 3, 6, 7, 10, 344, a self row in its own corridor at three keys.
    admit(family, door) is a door's call: the row, built, on a built base
    (standing), inside its corridor.
  • The corridor runs at admission (the piece v59 left for the tag): the
    self-roster door's policy is the table's self_roster_add /
    self_roster_evict rows (self_roster::policy() now returns Result), read
    at every roster change (gate), every execute and every standing read. A row
    outside its corridor, unreadable, or two self rows that disagree refuse the
    change before anything is held: new Error::GovernancePolicyRefused { family, reason }, kind() federation_governance_policy_refused, Python
    ValueError("federation_governance_policy_refused: {family}: {reason}"),
    reason a CorridorRefusal token or one of policy_row_missing,
    policy_not_two_path, policy_row_unreadable, policy_base_not_built,
    policy_rows_disagree. With the vendored table it does not arise (every
    two-path row is in its corridor, I1303). TwoPathPolicy::self_roster(window)
    is removed (clean break): the table is the one source; the test-anchor
    W_self seam (#1111) still shortens the window of the table's policy.
  • Re-run at the tag's pins: the 732 CC governance vectors replay through
    the RUNTIME reader (PolicyTable::policy_of, each family row also checked
    in its corridor); the six self_roster traces replay under the table's
    self-roster policy.
  • Refusal tokens at the pin (I1308): rc9 names federation_set_semantics_unsorted,
    federation_malformed_row and the reasons aggregate_source_duplicate,
    envelope_member_missing, typed_diverges_from_envelope, all spelled as
    persist emits them, and envelope_member_null (CC 2.6.1.1.1 finding F4),
    which persist does not emit: that is CIRISPersist#1098, kept out of v60 by
    the task plan (§2). No CC text at rc9 (or on the rc10 branch at 9f6ff75)
    names key_id_fingerprint_mismatch or carrier_without_roster_quorum; the
    witness latches both lists.
  • Witnesses: I1300 (every vendored CC file hashes to its pin: the registry, its
    vectors, the four manifests, the six traces), I1301 (CIRISServer's catalogue
    pin names the vendored vocabulary), I1302 (the pins are git show v1.0-rc9: bytes and the tag is VENDORED_CC_COMMIT, when a
    CIRISConstitution checkout is present: $CIRIS_CC_REPO or a sibling
    directory; it skips, saying so, without one), I1303–I1306 (the table: every
    two-path row in its corridor, the self-roster policy is the table's at every
    roster size 1..=12, each corridor clause refused by its token, an unreadable
    row refused not guessed), I1307 (sqlite, postgres: under a table
    whose self row is outside its corridor every roster change is refused and
    nothing is held; under the vendored table the same changes land or are
    held), I1308. scripts/check_vendored_cc.sh compares all twelve files.
  • Censuses moved by the re-vendor: PINNING_FAMILIES gains
    identity:occurrence_revocation (-1 only, 16); the contradiction-count
    contrast is 150 / 16 of 176.
  • Evidence: CLM-governance-policy-table (4.5.13) at
    governance_policy.rs#PolicyTable::admit; CLM-policy-corridor (4.2.1.1)
    at governance_policy.rs#PolicyTable::check_corridor and
    two_path.rs#TwoPathPolicy::check_corridor.
  • Mutation: scripts/mutants/scope-s6v-vendor.txt (with #1114's three
    functions), 108 mutants, three weekly shards in mutants.yml.
  • No migration. No store-trait method added or removed.

Changed — 7. three signed-object refusals carry their own malformed_row reasons (S6v; CIRISPersist#1114, CC 2.6.1.7 rc10)

The steward's ruling on CC rc10 99bf187 (2026-10-21) appends three reasons
to the closed federation_malformed_row set (appended, never reordered: the
set is a hashed vocabulary). Each refusal keeps the Python type of the arm it
replaces (ValueError throughout); only the kind() token and the reason move.

reason condition before
content_hash_mismatch original_content_hash ≠ SHA-256 of JCS(envelope) (tier_ingest::check_content_hash) federation_tier_unverified, free-text reason
envelope_row_member_unknown the signed row mirror carries an eighth member (admission::check_row_mirror_members_closed, ahead of the typed read) federation_malformed_row / envelope_member_invalid
subject_key_id_malformed a subject_key_ids element is neither a canonical lowercase key_id nor canonical:sha256:<hex64> (validate_subject_key_ids, every door that asks it: the attestation door and emit_attestation) federation_invalid_argument
  • Edge: a sender that bounded federation_tier_unverified or
    federation_invalid_argument as retryable now sees the TERMINAL
    federation_malformed_row for these three; a mirror that is missing a
    member is still envelope_member_invalid. The key-registration plane's own
    registration original_content_hash mismatch is unchanged (not in #1114).
  • The three are rc10 tokens, after the v1.0-rc9 tag persist vendors: I1308
    latches them as "named by no CC text at the pin" until the next re-pin.
  • Witnesses I1309–I1311 (sqlite, postgres) through put_attestation.
  • Evidence: CLM-signed-object (2.6.1.7) at the three symbols.
  • Mutation: the three functions join scope-s6v-vendor.txt.

Changed — 8. an organisation is an affiliation; the three operational planes retire (S6; CIRISPersist#1036 items 1–5, #1049, #1062, #1048; CC rc9)

CC 3.3.9 moves organization, org_membership and partner_record onto the
rows every community already uses (CIRISConstitution#161, #162, #164, #167,
#168, #163). An organisation is a community record at
cohort_scope: affiliations, named as an authority by its
community_key_id; a member is a membership:{stage} seat plus a hierarchy
delegates_to; a partner is a licensure:{community_key_id}:v1 row carrying
the typed licence payload. No Portal data existed to migrate (operator
ruling).

  • Item 1, the authority quorum (CLM-licence-authority-quorum,
    CLM-authority-id-is-org-id). Under a quorum consensus_protocol a licence
    is a COSIGNED row: its full scrub set (base scrub + additional_scrubs[])
    intersected with the authority community's roster at the instant, each
    survivor re-verified by reverse_quorum::count_distinct_roster_scrubs, is
    evaluated under the community's own protocol. One member's signature is
    testimony; a stranger's cosignature counts for nothing. The license
    chain roots at an edge the quorum cosigned that names the affiliation
    (community_id); a lone member's appointment roots nothing; onward hops
    need sub_delegation; a root edge is retracted only by a cosigned
    withdraws (rule 0). licensure_delegator_not_authority admits this case.
    admission::licence_authority_set reads the set.
  • Item 2 and #1049, judged at the signed instant with W in front
    (CLM-licence-judged-at-issuance, CLM-admission-freshness-window). A
    licence, a consent row's grant chain and a key_grant row's sealer chain
    (and the epoch-axis membership) are judged at the row's signed
    asserted_at (grant_chain_instant is now the row's instant, not receipt).
    Signed more than W = 72 h ago (self_roster::outside_past_freshness_window,
    one reading of W for every door), the standing must ALSO hold at receipt,
    else asserted_at_stale (Error::AssertedAtStale, plane licensure /
    consent / key_grant / owner_binding). This is the CC 3.3.6.3
    2026-10-19 reading (#1099: W binds a signer that has lost standing), so a
    new node syncing history admits its legitimate old rows. The stream read
    path judges a sealer's chain at the stored grant rows' instants
    (key_grant::stream_grant_row_instants; no migration), so a read never
    refuses a grant admission accepted. A withdrawal stops future issuance and
    does not reclassify a licence already issued.
  • Item 3, the audience (CLM-licence-audience). A licence has no public
    default: for a licence under an affiliation, the charter's new
    affiliation_config.licence_audiences ({license_type: cohort_scope},
    validated at the community door, licence_audience_invalid) is the
    declared scope per class; a class with no declaration may take any scope
    but a commons one. A non-public licence reaches other readers by
    RE-EMISSION: a new row (member reemission_of = the held licence's id),
    signed and cosigned again at the same scope, naming its readers in
    subject_key_ids, payload and status unchanged. Refusal
    federation_invalid_argument prefixed licence_audience_refused: with
    reasons class_not_declared_public, scope_not_the_declared_choice,
    re_emission_names_no_reader, re_emission_target_unknown,
    re_emission_diverges. The authority head a verifier needs (roster,
    protocol, license edges) travels on its own planes.
  • Item 4, the kinds' move. operational::PartnerRecord is the typed
    licence payload (envelope member partner_record on a licensure: scores
    row). Ported: the field set (malformed → federation_malformed_row
    envelope_member_invalid); the revision anti-rollback against the
    AUTHORITY's own held licences only (federation_partner_record_rollback;
    a stranger's testimony neither counts nor is counted); the ±5 min skew
    bound; payment-processor rejection over the whole envelope. The four
    set-semantics arrays must be sorted by JCS string order (UTF-16 code
    units) AND unique: a repeat, which v59 let through, is
    federation_set_semantics_unsorted; producers call
    PartnerRecord::canonicalize_sets before signing. The promotion
    chokepoint re-runs the payload, revision, audience, owner-binding and
    term gates. V205 (both dialects) drops federation_organizations,
    federation_org_memberships, federation_partner_records and their
    signed_wire_index / known_wire_hashes rows. The replication kinds
    Organization, OrgMembership, PartnerRecord join RETIRED_FRAMED at
    their hashed positions in EnvelopeKind::ALL (nothing renumbered; the
    manifest hash Edge and Server pin does not move): an inbound object is
    refused federation_envelope_kind_retired. CLM-merge (5.3.2.3) moves from
    the retired SubjectKind::merge_intent to the licence's revision gate.
  • Item 5, contest (CLM-licence-contest). A subject's withdraws against
    a licensure:* or revocation:* row about itself is refused
    (SUBJECT_WITHDRAWS_CARVE_OUT = capacity:, detection:, licensure:,
    revocation:; the v59-deferred widening in
    resolve_withdraws_admission_rule_at). The contest is
    reconsideration:{grounds}: the licensee files; for a licensure:{A}
    target A's authority set (plus any review-scoped chain from it) rules.
  • #1062, the organisation on the bond (CLM-org-on-the-bond). An
    owner-binding that names a community is admitted only when that community
    is an affiliation this node holds and the row is cosigned under its
    protocol at its signed instant; refusal federation_invalid_argument
    prefixed owner_binding_affiliation_refused: (affiliation_not_held,
    not_an_affiliation, not_cosigned_under_protocol). New fold
    admission::affiliation_of_binding(key). A binding naming no community is
    a personal steward's binding, unchanged.
  • #1048, delegation terms (CLM-delegation-term). A delegates_to
    carrying license, grant, moderate, takedown, review or slash,
    or sub_delegation, or naming an affiliation (its hierarchy), MUST carry
    delegation_valid_until at most 365 days after delegation_valid_from
    (absent = the edge's asserted_at), and within the affiliation's
    role_term; otherwise federation_delegation_term_required
    (Error::DelegationTermRequired, rules term_missing, term_unreadable,
    over_ceiling, over_role_term). Owner-bindings and the trust plane are
    not gated; an edge that needs no term stays open-ended. The licence and
    moderation walks read the affiliation's role_term. Grandfathered: an edge
    signed before DELEGATION_TERM_ADOPTED_AT (2026-10-23) keeps the v54
    reading. From the parked v54.1-rc8-term-gate, rebuilt on the v60 doors.

Python surface (clean break):

method v59 v60
put_organization, put_org_membership, put_partner_record (signed_json) -> None REMOVED
list_organizations_for, list_org_memberships_for, list_partner_records_for (id) -> str REMOVED
list_organizations_since, list_org_memberships_since, list_partner_records_since, list_signed_partner_records_since (since_rfc3339, since_id, limit) -> str REMOVED
grant_delegation (delegate_key_id, scopes, sub_delegation, delegation_purpose=None) + delegation_valid_from=None, delegation_valid_until=None (RFC 3339); a term-bearing edge without a bounded _until raises ValueError (delegation_term_required)
add_moderator (community_id, moderator_key_id, duty) + delegation_valid_from=None, delegation_valid_until=None; an appointment without a term raises ValueError

Error kinds gone with the planes: federation_operational_authority,
federation_operational_envelope_unbound.

Rust surface (clean break): FederationDirectory::{put_organization, put_org_membership, put_partner_record, list_organizations_for, list_org_memberships_for, list_partner_records_for, list_organizations_since, list_org_memberships_since, list_partner_records_since, list_signed_partner_records_since} removed (102 trait methods delegate in
the directory double, from 112). Gone from operational: SubjectKind,
MergeIntent, Organization, OrgMembership, the signed and Served*
wrappers, the role-authority / steward-quorum / LWW / monotonic-quorum
helpers; Error::{OperationalAuthority, OperationalEnvelopeUnbound}. New:
operational::{PartnerRecord (the payload), check_partner_record_sets, partner_record_of, check_licence_payload};
admission::{check_partner_record_revision, check_licence_audience, check_owner_binding_affiliation_admission, affiliation_of_binding, licence_authority_set, check_delegation_term_admission, DelegationTermBounds, is_term_bearing_edge, TERM_BEARING_SCOPES, SUBJECT_WITHDRAWS_CARVE_OUT};
affiliation_config::role_term_seconds; Error::DelegationTermRequired;
Engine::grant_delegation / add_moderator take a DelegationTermBounds.
DIRECTORY_ABI_VERSION 10 → 11: DirectoryOp::{ListOrganizationsSince, ListOrgMembershipsSince} and DirectoryOpResult::{Organizations, OrgMemberships} removed.

What Edge, Server and Agent adapt: §16.

  • Witnesses (sqlite, postgres unless noted): I1210 the payload gate;
    I1211 the revision anti-rollback; I1212 the skew bound; I1213
    the retired kinds and V205; I1214–I1215 item 1; I1216–I1217 item
    3; I1218 item 5; I1219 #1062; I1220–I1221 #1049 (consent,
    key_grant); I1222 (unit) UTF-16 set order; I1223 the promotion
    chokepoint re-runs the payload gate; I1230, I1234 (unit) the term
    lens and the grandfather ruling; I1231 the term gate; I1232 the
    walks read role_term; I1233 (sqlite engine) the producer doors;
    I1235 the promotion chokepoint re-runs the term gate; I1225 (unit)
    W's boundary. Extended: I972 (a licensee files), I313 (the stream read
    keeps a wrap only from a sealer with standing at its row's instant), I798
    (its duty edge carries a term). Python:
    tests/python/test_affiliation_surface.py.
  • Mutation scope scripts/mutants/scope-s6-affiliations.txt: 212 mutants in
    five shards, 186 caught, 26 unviable, 0 missed, nothing allow-listed (three
    rounds: 28, then 9 survivors, all witnessed or removed as equivalent).
    Weekly, five shards, in mutants.yml.
  • resolve_withdraws_admission_rule_at: rule 3 no longer accumulates
    beyond_delegation_depth_cap (rule 4 walks the same graph over a superset
    of targets; the refusal reports the same cut).
  • Evidence (manifest round v60-s6): CLM-authority-id-is-org-id (3.3.9),
    CLM-licence-authority-quorum ×2, CLM-licence-judged-at-issuance,
    CLM-licence-audience (2.4.1.2.1), CLM-licence-contest ×2 (2.4.1.1),
    CLM-org-on-the-bond ×2 (3.2), CLM-delegation-term (4.4.3.2.8),
    CLM-admission-freshness-window ×3 (2.6.7), CLM-canonicalization-array
    (2.6.1.1.1); CLM-merge repointed.

Changed — 9. hard_case rows replicate (S0; CIRISConstitution#170 closed)

The steward ruled through CC (2026-10-21, CIRISConstitution#170 closed, rc9
6ce1674): hard_case:* joins CC 2.4.1.1's unpurgeable accountability set,
and no peer capability advertisement is needed (conformance is the guard).
Persist already refused a hard_case purge (ACCOUNTABILITY_RECORD_PREFIXES,
both purge doors and the replicated purge apply), so the v57 hold is lifted:
a hard_case row crosses like any local row this node attests (the node signs
as the actor) and a peer admits it at the put door and the replicated apply.

  • Removed (clean break): Error::HardCaseReplicationHeld, its kind
    federation_hard_case_replication_held (hard_case::refusal::REPLICATION_HELD),
    hard_case::check_hard_case_replication_held, and its Python arm (a
    RuntimeError); no call site returns it any more. Nothing in the .pyi
    named it.
  • Changed: hard_case::project_hard_case_row is tier-agnostic (the
    detection projection's rule), and the put door (also the replicated apply)
    projects a hard_case:* row into hard_case_events in the row's
    transaction on every backend. Without both, a crossed row (moved to the
    federation tier in place) and a peer's replicated row would each leave
    hard_case_events different from its from-rows rebuild. A peer's events
    are therefore listed by list_hard_case_events on the node that holds them.
  • Added: the listing now includes replicated peers' events, each
    carrying HardCaseEvent::emitter_key_id (the key that signed its row,
    never detail); filter by HardCaseFilter::emitter_key_id for a
    node-local view. V202 (both dialects) adds the
    hard_case_events.emitter_key_id column, backfilled from each projected
    row's signer.
  • Witness I1165 (sqlite, postgres): node A records a
    hard_case:consent_sla_breach:v1 row and crosses it; node B receives it
    only through A's advertised wire hashes and point read, applies it
    (Inserted), holds it at the federation tier, lists it, serves it, and
    both nodes' hard_case_events equal their rebuilds; B's purge doors refuse
    it as the accountability record (the dry run names it inadmissible; local
    execution and a quorum-cosigned replicated bundle are both
    purge_retraction_inadmissible, nothing written; the row survives). The
    #607 conferral witness now expects the self-attested and conferred
    hard_case rows ADMITTED where it expected the hold.
  • Evidence: CLM-hard-case-on-base's row at the retired hold moves to the
    replicated apply door (attestation_apply.rs#apply_planned);
    CLM-accountability-record-unpurgeable (2.4.1.1) gains
    steward_purge.rs#accountability_record_of.

Fixed — 10. nodes_owned_by returns the nodes a roster key announced (S0; CIRISPersist#1109)

admission::nodes_owned_by(fedID) sought its candidates only among the
delegates_to owner-bindings the fedID's own key signed. A device joined by
approval announces its node with its roster key (<fedID>-<fp>), and since
v59.0.0 owner_of credits that binding to the fedID (self_of_at), so the
forward walk named the self as owner while the reverse walk never placed the
node. The seek now also reads the bindings every key bound as an occurrence
of the fedID signed; the verdict is still owner_of, so n ∈ nodes_owned_by(U) ⟺ owner_of(n) == U holds by construction and a key of
another self, or a key that bound a node after its eviction, adds nothing.
nodes_owned_by now returns roster-key-announced nodes: CIRISServer can
delete its announced_nodes_of workaround. Witness I1161 (sqlite,
postgres).

Added — 11. a test-anchor seam on W_self (S0; CIRISPersist#1111, CIRISServer#794 T11)

A test-anchor harness binary could not watch the self roster's lone path
land: W_SELF_SECS is 86 400 s and the node clock's seam is cfg(test)-only.
self_roster::policy() now reads self_roster::w_self_secs(), which is
W_SELF_SECS on every production build. Under feature = "test-anchor"
ONLY, and only while the test-anchor override is live (the existing fence:
the feature, CIRIS_TESTING_MODE, a decodable CIRIS_TEST_TRUST_ROOT, and
verify's anti-production tripwire), CIRIS_TEST_W_SELF_SECS (whole seconds
in 1..=86400, read once per process; it can shorten the window, never
lengthen it) replaces it. The window still runs on the node's clock from the
node's receipt. Witness I1162: armed with the variable set, an unanswered
addition lands after the short window on sqlite and postgres; with the
variable set and no override it does not
(tests/self_roster_test_window_1111.rs, its own process: #738); the lib
half pins the override condition and the parse, and a from-disk check pins
every read of the variable inside the #[cfg(feature = "test-anchor")]
module self_roster::test_window.

Added — 12. one mutation discipline: a scope per slice, checked from disk (S0; docs/V60_TASK_PLAN.md §3)

Every slice since v53 ran its mutation round as an ad-hoc script outside the
repo; nobody could re-run one, and the nightly matrix never learned the sites.
From v60 each slice ships scripts/mutants/scope-<slice>.txt and runs it
through scripts/mutants.sh; its matrix is the handoff's mutation table, and
green is exit 0 with an empty unvalidated.txt (docs/MUTATION_TESTING.md
"Per-slice scopes (v60)": the rule, a scope-file template, the brief
paragraph agents copy).

  • scripts/mutants_scopes_check.py (stdlib, from disk): every scope resolves
    through mutants_matrix.py resolve, its tests: filter is a well-formed
    nextest filterset whose test(...) leaves match a fn or mod name under
    src/ or tests/, its features: exist in Cargo.toml, and every
    allowed_survivors.txt line names an issue; --self-test shows it fails on
    each. A certify fast gate (mutscopes, beside mutmatrix), and a step in
    ci.yml and the nightly mutants.yml.
  • The scope grammar gains features: (read by scripts/mutants.sh;
    MUTANTS_FEATURES still wins): a site under cfg(feature = "test-anchor")
    mutated in a build without the feature is never compiled, and every mutant
    in it would read MISSED.
  • scripts/mutants/scope-s0-roster-seam.txt: #1109 and #1111's doors
    against I1161/I1162, the first scope with features: …,test-anchor.
  • reports (non-gating) runs scripts/mutants_diff.sh origin/main on every
    release-* PR (it ran only on a run-mutants label), capped by
    MUTANTS_DIFF_BUDGET_SECS (7200 s) inside the job's 150 minutes; the
    summary lands in the step summary and the artifact.

Added — 13. the CSD/4 coverage report (S0; docs/V60_TASK_PLAN.md §3b, report-only)

  • tests/fixtures/csd4/: CIRISClient's packaging/csd4/ (traces.json,
    requirements.yaml, PIN.md, topology_vocabulary.pin) byte for byte,
    from CIRISClient main ab134ef2; the README records the commit and the
    shas. topology_vocabulary.json moved to the runtime manifests at the rc9
    tag (§6).
  • csd4_chain.json: CC's chain as data, now a runtime manifest at
    src/federation/namespace/csd4_chain.json, byte-identical to git show v1.0-rc9:manifests/csd4_chain.json (§6): per atom its STATE_TABLE states, the invariants
    and claims over them (by_invariant | by_section), each claim's evidence
    pointers by repo, and the text's refusal tokens with their confidence.
  • scripts/csd4_coverage.py (stdlib, from disk) READS the join from the chain:
    atom → claim → CIRISPersist path#symbol → the evidence/cc_impl.tsv row →
    the *_invariants.rs files that name the symbol and the mutation scopes that
    cover it. Gap lists: (a) chain refs with no row (or under another claim), and
    rows no atom reaches; (b) atoms with persist claims but no witness or no
    scope; (c) persist kind() tokens the text does not name, and text tokens
    persist never emits, with bold/after_refusal confidence. negatives.held
    and values.ready are listed as harness-level, not gaps. The traces.json
    refused map and CEWPOS's "stops at rel[k]" relations (with an owner guess)
    stay. docs/CSD4_COVERAGE_v60_S0.md is its run at the slice head. A
    reports (non-gating) job runs it on every PR and weekly.

Fixed — 14. the nightly mutants job is green again (S0; CIRISPersist#1112)

The scheduled mutants and powerset job had been red every day since
2026-10-14 with seven unkilled mutants in src/observe, and nobody read it.

  • count_read's walk over the active-fold mask is bounded by the mask's
    popcount instead of while mask != 0. Each of the three operator mutants on
    the clear-lowest-bit step made the old loop infinite; seventeen witnesses
    hung to nextest's 60 s leash and the run outlived cargo-mutants' 300 s
    timeout, which credits no witness (three TIMEOUTs). A witness cannot end an
    infinite loop, so the bound makes the same mutants return at once with the
    wrong fold credited, and I1164 (a read inside three nested folds at
    mask bits 0, 4 and 7 credits each exactly once) kills them.
  • I1163: CatalogEntry::metrics_unit refuses every malformed brace form
    ({}, ab}, abc, }, x}, {ab, {x, {, x, empty). The four
    MISSED guard mutants had no witness because the catalogue holds only
    well-formed units.

Security — 15. three advisories published after v59.1.0 (deny.toml)

  • RUSTSEC-2026-0386 (tokio AsyncFd::new I/O safety): cleared, tokio 1.52.3
    → 1.54.1 in Cargo.lock.
  • RUSTSEC-2026-0385 (ctutils non-constant-time masks): cleared, ctutils 0.4.2
    → 0.4.6 in Cargo.lock.
  • RUSTSEC-2026-0387 (crypto-bigint 0.5 secret-dependent branches): ignored
    with its reason. The fix is 0.7.6, which elliptic-curve 0.13 cannot take;
    the crate is transitive only via p256 0.13 in CIRISVerify v20.2.0's
    ciris-crypto / ciris-keyring, and persist's federation identity is Ed25519
    • ML-DSA-65 (a P-256 platform signer is refused as the federation key).
      Re-evaluate on the next ciris-verify bump.

Adoption — 16. what CIRISEdge, CIRISServer and CIRISAgent must handle

Every break in v60.0.0, from git diff v59.1.0.. (python .pyi, src/ffi,
the exhaustive enums, the migrations). Edge adapts once, to this head.

Replication kinds (wire).

  • EnvelopeKind::{Organization, OrgMembership, PartnerRecord} join
    RETIRED_FRAMED (now 7: the four key-plane kinds from v59 and these
    three). An inbound object of these kinds is refused
    federation_envelope_kind_retired; send nothing on them. Their positions
    in EnvelopeKind::ALL (19) are kept, so the manifest hash Edge and Server
    pin does not move. Live kinds: 12. (§8)
  • The cirisnode classes (Contribution, vote, moderation, slashing,
    reconsideration, ledger balances, takedown notice) and Goals never had a
    replication kind; they now cross as scores rows on the attestation
    plane. Edge retires its own GoalDeclaration / GoalRetirement messages,
    which wrapped persist's Goal. (§1, §2)
  • A v59 peer's purge bundle carrying its takedown notice as a takedown_notice
    Contribution is still applied for one release (v60.x; removed in v61). (§1)

Python methods removed (.pyi and src/ffi/pyo3.rs):

  • cirisnode_list_takedowns_for_json, cirisnode_list_key_grants_for_json,
    cirisnode_list_key_grants_for_content_json,
    cirisnode_list_key_grants_for_filtered_json,
    cirisnode_list_key_grants_for_scope_epoch_json,
    cirisnode_put_key_grant_json, cirisnode_retire_key_grants_json (§1; the
    key-grant plane, key_grant:{axis}:v1, is the one door for a wrapped key).
  • put_organization, put_org_membership, put_partner_record,
    list_organizations_for, list_org_memberships_for,
    list_partner_records_for, list_organizations_since,
    list_org_memberships_since, list_partner_records_since,
    list_signed_partner_records_since (§8).

Python methods re-signatured (each now returns the row id it wrote,
signed by the engine's own hybrid signer, where it returned None):

  • cirisnode_put_contribution(body_json) -> str,
    cirisnode_cast_vote(body_json) -> str,
    cirisnode_update_credits_ledger(update_json) -> str,
    cirisnode_update_expertise_ledger(update_json) -> str,
    cirisnode_put_moderation_event(body_json) -> str,
    cirisnode_put_slashing_attestation(body_json) -> str,
    cirisnode_put_reconsideration_request(body_json) -> str,
    cirisnode_put_reconsideration_attestation(body_json) -> str (bodies in
    the §1 table, not signed envelopes).
  • cirisnode_list_contributions(author_id, filter_json=None, cursor_json=None, limit=100), cirisnode_list_votes(contribution_id, voter_id=None, cursor_json=None, limit=100): items are rows, the cursor an
    AttestationCursor.
  • cirisnode_list_takedowns_for_filtered_json: filter_json must name
    claimant_key_id; items are notice rows.
  • cirisnode_process_takedown_admission_json(notice_attestation_id) (was the
    Contribution JSON); the report adds notice_attestation_id.
  • cirisnode_get_credits_ledger / cirisnode_get_expertise_ledger: the entry
    adds attestation_id; created_at is the row's instant.
  • cirisnode_put_promotion_attestation: target_ids are held row ids (no
    is_canonical flip). cirisnode_put_delivery_attestation: the
    announcement must be a held contribution:federation_announcement:v1 row.
  • cirisnode_put_goal_json(body_json) -> str (a v59 typed Goal raises
    goal_typed_object_retired), cirisnode_retire_goal_json(goal_id) -> str
    (no retired_at argument), cirisnode_get_goal_json(goal_id) (goal_id
    is the declaration's row id, not a UUID; the JSON carries scale /
    cohort_id, score, attestation_id), cirisnode_list_goals_json (filter
    scale, not scope_kind; unknown keys refused). (§2)
  • grant_delegation(..., delegation_valid_from=None, delegation_valid_until=None) and add_moderator(..., delegation_valid_from=None, delegation_valid_until=None) (RFC 3339): a term-bearing edge (license,
    grant, moderate, takedown, review, slash, sub_delegation, or one
    naming an affiliation) without a bounded _until raises ValueError
    (federation_delegation_term_required). (§8)

Python methods added: cirisnode_rehome_ledgers() -> int,
cirisnode_check_ledger_projection(projection) -> str,
cirisnode_amend_goal_json(goal_id, body_json) -> str,
cirisnode_recant_goal_json(goal_id) -> str, cirisnode_rehome_goals() -> str,
cirisnode_check_goal_projection() -> str. Run the two rehomes once after
upgrading a node that held v59 ledgers or goals (§1, §2).

Refusal tokens and error kinds.

  • New kind() tokens: federation_governance_policy_refused
    (Error::GovernancePolicyRefused { family, reason }, ValueError; reasons
    a CorridorRefusal token or policy_row_missing, policy_not_two_path,
    policy_row_unreadable, policy_base_not_built, policy_rows_disagree;
    §6), federation_delegation_term_required (Error::DelegationTermRequired,
    ValueError; rules term_missing, term_unreadable, over_ceiling,
    over_role_term; §8), cirisnode_key_grant_retired and
    cirisnode_typed_object_retired (cirisnode::Error::Retired; §1),
    ceremony_scratch_directory (CeremonyOutputsRefusal::ScratchDirectory;
    §5).
  • New federation_malformed_row reasons, appended to the hashed
    malformed_row::ALL (nothing reordered): goal_meta_alignment_malformed,
    goal_body_malformed, goal_typed_object_retired (§2),
    content_hash_mismatch, envelope_row_member_unknown,
    subject_key_id_malformed (§7).
  • Moved token (#1114, §7, ValueError throughout): a content-hash mismatch
    was federation_tier_unverified and a malformed subject_key_ids element
    was federation_invalid_argument; both are now the TERMINAL
    federation_malformed_row. A sender that retried either must stop.
  • New federation_invalid_argument prefixes (ValueError):
    licence_audience_refused: (class_not_declared_public,
    scope_not_the_declared_choice, re_emission_names_no_reader,
    re_emission_target_unknown, re_emission_diverges),
    owner_binding_affiliation_refused: (affiliation_not_held,
    not_an_affiliation, not_cosigned_under_protocol). New
    Error::AffiliationConfigInvalid rule licence_audience_invalid at the
    community door. (§8)
  • asserted_at_stale (Error::AssertedAtStale, kind still
    federation_invalid_argument) now also fires on the licensure,
    consent, key_grant and owner_binding planes; the variant's
    steward field is renamed standing_for and it gains plane. A
    repeated licence set entry is now federation_set_semantics_unsorted.
    (§8)
  • Retired, no longer returned: federation_hard_case_replication_held
    (Error::HardCaseReplicationHeld, was a RuntimeError; §9),
    federation_operational_authority, federation_operational_envelope_unbound
    (Error::{OperationalAuthority, OperationalEnvelopeUnbound}; §8).
  • A postgres grant_trust row that trips a V020 CHECK reads Backend, not
    InvalidArgument (the plan refuses both shapes first; §4).

Exhaustive enums that changed (a match without a wildcard breaks):
federation::Error (+GovernancePolicyRefused, +DelegationTermRequired,
−HardCaseReplicationHeld, −OperationalAuthority,
−OperationalEnvelopeUnbound; AssertedAtStale's fields),
cirisnode::Error (+Retired, +Federation), CeremonyOutputsRefusal
(+ScratchDirectory), SiloProjection (+CreditsLedger,
+ExpertiseLedger, +Goals), DirectoryOp (+ListKeyPlaneWireHashesSince,
−ListOrganizationsSince, −ListOrgMembershipsSince),
DirectoryOpResult (−Organizations, −OrgMemberships),
observe::StoreBackend (−Memory). Removed public enums:
operational::{SubjectKind, MergeIntent}, SubjectGate,
cirisnode::{RetireKeyGrantsOutcome, RetireFailureStage, KeyGrantScope, WrapAlgorithm}; GoalScope is a validated struct, not an enum. New:
LegacyLedgerRow, ledger_projection::{Ledger, LedgerBody},
takedown_tombstone::NoticeOrigin. LegalBasis decodes CC's spellings
dmca512 / dsa_article16 only.

Exhaustive structs that gained a field: HardCaseEvent.emitter_key_id,
HardCaseFilter.emitter_key_id (Default), CreditsLedgerEntry /
ExpertiseLedgerEntry.attestation_id, AffiliationConfig.licence_audiences,
TakedownReport.notice_attestation_id, TakedownTerms.row.

The directory capsule. DIRECTORY_ABI_VERSION 10 → 11 (the two org ops
and their results removed; ListKeyPlaneWireHashesSince appended, §4, §8).
A consumer's capsule-receive check (abi_version == DIRECTORY_ABI_VERSION)
built against 10 refuses the v60 capsule: rebuild against 11.

Rust surface (clean break, no aliases).

  • FederationDirectory: removed put_organization, put_org_membership,
    put_partner_record, list_organizations_for, list_org_memberships_for,
    list_partner_records_for, list_organizations_since,
    list_org_memberships_since, list_partner_records_since,
    list_signed_partner_records_since, put_goal, retire_goal; changed
    get_goal(&str); added legacy_goals, delete_legacy_goal,
    list_key_plane_wire_hashes_since (default Unsupported).
  • NodeCoreService and the cirisnode types per §1; Goal, GoalScope,
    GoalsFilter per §2; the inherent SqliteBackend / PostgresBackend
    key-record methods per §3; operational per §8;
    Engine::grant_delegation / add_moderator take a DelegationTermBounds;
    TwoPathPolicy::self_roster(window) removed and self_roster::policy()
    returns Result (§6); hard_case::check_hard_case_replication_held
    removed (§9).
  • store::MemoryBackend is gone (§5). Tests and embedders use
    store::test_backend::test_backend() (feature sqlite).
  • Genesis doors take a scratch (§5): verify_ceremony_outputs(bundle_json, scratch), install_genesis_bundle_roster(dir, scratch, bundle),
    CeremonyState::finish(scratch), AmendState::plan(pinned, rows, scratch),
    AmendState::finish(scratch). Engine::install_genesis_bundle_roster and
    the Python install_genesis_bundle_roster_json keep their signatures. On
    postgres the configured role needs CREATEDB
    for these doors (a
    throwaway ciris_genesis_scratch_* database per replay, reaped after an
    hour if a process died holding it).
  • takedown_tombstone::takedown_terms is async (directory, notice, origin);
    TakedownNoticePayload drops claimant_key_id / asserted_at and refuses
    unknown fields (§1).

Producers. Sign a licence as the licensure:{community_key_id}:v1 row,
cosigned under the authority's protocol in additional_scrubs[]; call
PartnerRecord::canonicalize_sets (sorted AND unique) before signing; pass a
term on every term-bearing delegates_to (§8). Sign a takedown notice as a
takedown_notice:{basis}:v1 row (§1).

Migrations (sqlite + postgres twins, run at open): V202
(hard_case_events.emitter_key_id, backfilled), V203 (drops eight
cirisnode tables; ledgers gain attestation_id; purge_executions.legal_basis
rewritten to CC's spelling), V204 (goals rebuilt as a projection:
scope_kind → scale), V205 (drops federation_organizations,
federation_org_memberships, federation_partner_records and their index
rows). Nothing outside persist wrote the dropped tables.

Telemetry. The backend label set is {sqlite, postgres}; a dashboard
keyed on backend="memory" sees no series (§5).

Per consumer.

  • CIRISEdge: everything under replication kinds, the capsule ABI, the
    exhaustive enums, the moved #1114 tokens, the Rust surface, producers. Its
    own test sites named by the slices: tests/federation_announcement.rs
    (announcement written through the removed typed put_contribution), the
    durability test's local takedown (built a Contribution notice), its
    GoalDeclaration (wraps Goal, built with the removed Goal::new).
  • CIRISServer: the Python methods above (cirisnode, goals, organisation,
    grant_delegation / add_moderator), the refusal tokens, CREATEDB for
    the genesis doors on postgres, the rehomes after upgrade; it can delete its
    announced_nodes_of workaround (§10).
  • CIRISAgent: the Python breaks above are the whole Python break; an Agent
    that calls none of the removed or re-signatured methods needs no change.
    The .pyi is the contract.