ADR-0044 — Enroll fails closed on actor_id collision; human actors carry a person-distinguishing determinant¶
Context¶
ADR-0011 fixed an actor's identity as the
content-address of its pinned determinant set, and ADR-0029
named the skill-epoch / served-model digest as pinned determinants. In db/004_actors.sql,
enroll_actor derives actor_id = cairn_actor_id(pinned) accordingly. For an AI agent the pinned set
is rich (vendor, model, version, config, skill-epoch, deploying node), so two distinct agents naturally get
distinct actor_ids. But ADR-0011 explicitly gives a human no behavioural-config dimension — and did
not say what else distinguishes two humans. The minimal human pinned set (e.g. {"role":"clinician"}) is
therefore identical across people, so two different clinicians compute the same actor_id.
actor_current is DISTINCT ON (actor_id) … ORDER BY (recorded_at, seq) DESC. When two keys share an
actor_id, the later enrollment's key wins and the earlier key silently drops out of actor_current —
the two humans are merged into one identity, and the survivor's key can author as the merged actor, with
no error. This is a silent identity-merge on the trust-anchor floor: a direct violation of principle 2
(identity is a claim; never merge — always link; never erase — always overlay). It surfaced during the
ADR-0043 owner-gate work (the tests hit it and
worked around it with distinct pinned tags) and was then confirmed against db/004.
The tempting fix — fold the signing key into actor_id — is wrong: ADR-0011 §5 mandates rotate-key,
under which an actor keeps the same actor_id across a key change (old publics retained for historical
verification). actor_id must stay stable across key rotation, so the key cannot be one of its
determinants. The distinguishing determinant for a human must be something other than the key and stable
across rotation.
Decision¶
-
enroll_actorfails closed on anactor_idcollision with a distinct key. Before inserting, it refuses if any existingactor_eventrow already binds the computedactor_idto a differentsigning_key_id— checked across the whole history, including revoked rows. Anactor_idis immortal: it is never reusable by a different key, even afterrevoke(principle 2, the immortal-UUID rule now applied to actors). The refusal is legible and names the remedy. Idempotent re-enroll with the same key passes while the actor is live (re-runnable provisioning; the per-epoch matcher-actor re-enroll) — but once theactor_idhas been revoked (or superseded), even a same-key re-enroll is refused, because a fresh enroll would outrank the revoke inactor_current's(recorded_at, seq)order and silently resurrect a retired actor. The whole-history check catches that edge at the DB floor itself, mirroring the Rustmatcher_actorresurrection guard. The check is a small pureSTABLEpredicatecairn_actor_id_key_conflict(actor_id, key), independently testable and reusable at the future actor-sync apply door;enroll_actortakes a txn-scoped advisory lock on theactor_idfirst, so the check is not a check-then-insert (TOCTOU) window under concurrent same-actor_idenrolls. -
A human actor's pinned set carries a person-distinguishing determinant. A handle / registration id (whatever the future §7.5 enrollment surface adopts) keeps two clinicians'
actor_ids genuinely distinct, exactly as two AI agents differ by their rich pinned sets. Cairn does not hard-code which field (ADR-0011 keeps pinned-set contents as policy); the floor rejection in (1) makes a forgotten determinant loud on the second enroll rather than silently merging. -
This is enforcement of ADR-0011's existing intent, not a new determinant. The
actor_idderivation is unchanged; (1) closes an enforcement gap the same way ADR-0043 closed the suppression owner-gate gap. It is scoped to the one implemented write door (enroll_actor); there is no remote-apply door for actor enrollment yet. When actor-event sync lands (ADR-0011 §4 makes enrollment partition-safe / syncable), the same check must be mirrored at that apply door — the analogue of the #154 apply-door caveat.
Canonical home: security §7.5.
Consequences¶
- Easier: two real clinicians (or two agents) can never silently merge on the trust anchor; the failure is loud, auditable, and repairable by adding a distinguishing determinant — no data loss.
- Harder: the future human-enrollment surface must supply a person-distinguishing determinant (a small, cheap-at-provisioning obligation). A second write door (actor-event sync) must carry the same check.
- Operational (agents/devices): losing a per-epoch key file (matcher/device) and regenerating it within
the same epoch now fails loudly at
enroll_actor(the regenerated key collides with the epoch's existingactor_id) instead of silently taking the identity over — the correct outcome, but untilrotate-keyis implemented the recovery is to bump the epoch (mint a new determinant set), not to re-enroll. This is consistent with the recall/immortality intent, and is exactly whatmatcher_actoralready refuses in Rust. - Concurrency: the advisory lock closes the collision race under the default READ COMMITTED isolation
(the only isolation the enrollment callers use); it does not, by itself, defeat a stale snapshot under
REPEATABLE READ / SERIALIZABLE — SERIALIZABLE would abort such a conflict as a serialization failure. A
unique/exclusion constraint would be a stronger guarantee, but the invariant ("≤ 1 distinct non-null key
per
actor_id, and no enroll after revoke") is not a plain uniqueness, and enrollment is a rare owner-gated ceremony — the lock is the proportionate floor. - The bet: the collision check plus a person-distinguishing human determinant is the whole fix — no change
to
actor_idderivation,actor_current, orrotate-key(which remains the sanctioned same-actor / new-key path). We would revisit if a legitimate flow needs two distinct actors to share oneactor_id(none is known). - No new founding principle. This is principle 2 applied to the actor registry — as ADR-0011 already established — now enforced at the enroll door.