Skip to content

Model multi-occurrence self to multi-occurrence self sessions - #512

Closed
emooreatx wants to merge 1 commit into
release/0.5.196-identity-and-sessionsfrom
feat/0.5.196-session-table
Closed

Model multi-occurrence self to multi-occurrence self sessions#512
emooreatx wants to merge 1 commit into
release/0.5.196-identity-and-sessionsfrom
feat/0.5.196-session-table

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Model + tests only. No callers yet — this is the shape we agreed on before anything is wired to it.

Stacked on #510 because session_table.rs doc-links crate::key_convention. Rebase to main once #510 lands.

The model

A self is a fed identity plus the N nodes it stewards — its occurrences. An attestation addressed to a fed id fans out to every one of them, because a fed id has no transport path of its own (#510 / key_convention). Exactly one occurrence may act on it.

It is a NAT table, not a leader election. A self is legitimately doing several things at once on different nodes:

my self (one fed id, four occurrences)
  ├── node 1  agent "scout"   ── session x ──▶ community A
  ├── node 2  agent "echo"    ── session y ──▶ community A
  ├── node 3  me, chatting    ── session z ──▶ community B
  └── node 4  me, on video    ── session a ──▶ community B

So the claim is keyed by (community, session) and resolves to the occurrence handling that session. "Which node is my self?" has no answer; "which node is handling session z in community B?" does.

Two primitives: claim a thing, and clear a claim.

An unclaimed attestation is never acted on

Not by a quorum, not by the lowest id, and not by a single-node self. Being the only occurrence confers no authority to act — it means there is one place where nobody is home.

Attendance has only Human and Agent. There is deliberately no "unattended" variant: an unattended occurrence is absent from the table rather than present with a weak claim, so no value exists that a careless comparison could promote into a right to act.

The agent's model ports; its mechanism does not

CIRISAgent solved this inside one runtime, and the model is the one to take over: an owning agent_occurrence_id on every row, a reserved __shared__ partition for unclaimed work, and transfer_task_ownership to take it.

The mechanism does not port. Those occurrences share one database — the config field says so outright, "enables multiple instances against same database" — so the claim is an atomic DB operation. Mesh occurrences each hold their own persist DB and reconcile by replication; there is no compare-and-swap across them.

Porting the transfer verbatim would also import a race already latent upstream: transfer_task_ownership reads the row, checks it still says __shared__, then upserts — two occurrences can both read, both pass, and both write. In one process that window is narrow; under replication lag it is the normal case.

So concurrent claims are treated as expected rather than prevented, and resolve() settles them with a rule every occurrence computes identically: earliest claim wins, ties on the lowest occurrence id. Convergent without coordination. Handling must still be idempotent per attestation id — determinism only bites once views agree, and lag means they transiently do not.

Replication: two paths, not one

  • Within a self — siblings learn claims on the CEG self tier. projection_for maps self/family to Projection::SelfOwn, advertised by nobody, so those rows move only by subject-initiated pull (pull_subject_testimony, fail-closed to the subject). My occurrences qualify; a peer never does.
  • Between selves — the peer learns ownership from a community-scoped claim under the directed-consent grant that already carries the conversation, so a sender can tell a considered silence from a dropped message.

⚠️ The second path has a failure mode this repo has already been bitten by. Grants cover prefixes, and contacts_chat records what happened when one was missing: a peer already federated with held a grant covering capacity:/trace: only, so chat: rows stayed ineligible and "the people you know best were exactly the people you could not message" — with every call reporting success. A claim on SESSION_ATTESTATION_PREFIX not in the grant replicates to nobody, silently, and the symptom is not an error but a peer who appears to be ignoring you. Any surface emitting claims must add the prefix through peer::ensure_contact_consent_covers, never assume it.

SESSION_CLAIM_DIMENSION = "session:claim:v1" is versioned (persist refuses MissingVersionSegment) and deliberately not under chat: — claims govern any addressed session, so scoping them to chat would leave video, moderation, and claim flows with no ownership signal.

Tests (8)

  • an unclaimed attestation is never acted on, even by a lone occurrence
  • four occurrences hold four concurrent sessions across two communities
  • a live claim is not stealable; renewal does not reset claimed_at
  • a stale claim becomes claimable again (a dead node must not hold sessions forever)
  • only the holder can clear its own claim
  • concurrent claims converge on the same survivor from either arrival order
  • a simultaneous tie breaks on the lowest occurrence id
  • the claim dimension is versioned and matches its consent prefix

Not in this PR

  • No callers. Nothing emits or consumes a claim yet.
  • The claim's on-wire CEG envelope shape is named, not built.
  • Liveness (CLAIM_TTL_SECS = 900) is a first guess, deliberately generous: reclaiming from a live-but-quiet occurrence yields two handlers for one session, which is worse than a late reply.

🤖 Generated with Claude Code

https://claude.ai/code/session_017gxnxsTWS8FkN3CT2JEz86

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx
emooreatx force-pushed the feat/0.5.196-session-table branch from f84a6c2 to a54867b Compare August 30, 2026 16:19
@emooreatx
emooreatx changed the base branch from fix/0.5.196-identity-consistency to release/0.5.196-identity-and-sessions August 30, 2026 17:56
A self is a fed identity plus the N nodes it stewards. An attestation addressed
to a fed id fans out to every one of them, because a fed id has no transport path
of its own. Exactly one occurrence may act on it.

It is a NAT table, not a leader election. A self is legitimately doing several
things at once on different nodes: two agents on their own nodes, me chatting on
a third, me on video on a fourth — four occurrences, four live sessions, two
communities, concurrently. So the claim is keyed by (community, session) and
resolves to the occurrence handling THAT session. "Which node is my self?" has no
answer; "which node is handling session z in community B?" does.

Two primitives: claim a thing, and clear a claim.

An unclaimed attestation is never acted on — not by a quorum, not by the lowest
id, and not by a single-node self. Being the only occurrence confers no authority
to act; it means there is one place where nobody is home. Attendance has no
"unattended" variant, so there is no value that could be promoted into a right to
act.

The agent's MODEL ports and its MECHANISM does not. Its occurrences share one
database ("enables multiple instances against same database"), so its claim is an
atomic DB operation. Mesh occurrences each hold their own persist DB, so there is
no compare-and-swap across them. Porting transfer_task_ownership verbatim would
import a race already latent upstream — it reads, checks __shared__, then upserts,
and two occurrences can both read, both pass, and both write. In one process that
window is narrow; under replication lag it is the normal case.

So concurrent claims are expected rather than prevented, and resolve() settles
them with a rule every occurrence computes identically: earliest claim wins, ties
on the lowest occurrence id. Convergent without coordination. Handling must still
be idempotent per attestation id.

The table replicates on two paths. Within a self, siblings learn claims on the
CEG self tier, which moves only by subject-initiated pull. Between selves, the
peer learns ownership from a community-scoped claim under the directed-consent
grant — which must carry SESSION_ATTESTATION_PREFIX or every claim replicates to
nobody, silently, exactly as chat rows did when a grant covered capacity:/trace:
only and the people you knew best were the ones you could not message.

Model and tests only; no callers yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gxnxsTWS8FkN3CT2JEz86
@emooreatx
emooreatx force-pushed the feat/0.5.196-session-table branch from a54867b to c7d3e0b Compare August 30, 2026 17:58
@emooreatx

Copy link
Copy Markdown
Contributor Author

Closing unmerged in favour of CIRISAI/CIRISPersist#782, where this belongs.

Checking which layer actually owns the primitives answered the question decisively — persist already has all three, and needs no new ones:

need already in persist
owner → nodes walk engine.rs:6079 nodes_stewarded_by (this repo only delegates)
self-plane visibility federation/namespace/mod.rs:106 Projection::SelfOwn
the claim row EnvelopeKind::Attestation"each family is a claim-dimension, not a wire kind"

The decisive detail: projection_for(cohort_scope, authority, is_tombstone) dispatches on cohort_scope, not on dimension. Both replication paths described in this PR fall straight out of the existing function — selfSelfOwn for sibling occurrences, communityCohort for the peer. Same claim, two scopes, nothing to add.

It also found a place where persist is simply better than what I wrote here: is_tombstone → Global, monotonic, "it can never be out-run by the stale record it retracts." Modelling a claim release as a tombstone rather than the local rows.remove() in this branch makes clearing anti-rollback by construction.

And the merge rule — earliest claim wins, ties on lowest occurrence id — is the same class of fold persist already does for withdraws/supersedes in ConsentPeerSet. Putting it there means every node type converges by construction instead of each consumer reimplementing it.

Merging a server-side home would have created exactly the defect I filed against edge this morning (CIRISEdge#548): two crates implementing one rule, where the copy forks silently and the test pinning it keeps passing. Doing that knowingly, hours later, in the same arc, would be hard to defend.

Nothing is lost — this had no callers, which made now the cheapest possible moment to move it. The branch stays; persist#782 references commit c7d3e0b and carries the model, the layer argument, and all 8 tests for porting.

Server keeps the half that is genuinely its own: attendance. "A human is present here" is not a storage fact and persist cannot know it — this node observes it and asserts it.

@emooreatx emooreatx closed this Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant