@@ -8,9 +8,24 @@ import { isRecord } from '@agent-device/kernel/record';
88
99import { canonicalLocalDeviceKey } from './device-claim-paths.ts' ;
1010
11- /** Schema version of a process-owned claim record; v1/v2 records migrate to it on read. */
11+ /** Schema version of a process-owned claim record; v1/v2 records normalize to it on read. */
1212export const DEVICE_CLAIM_SCHEMA_VERSION = 3 ;
1313
14+ /**
15+ * What a claim's record states about who owns it. A current writer always states one.
16+ * `unstated` is what a record migrated from the released v1/v2 schemas decodes to, because those
17+ * schemas wrote a command-scoped claim and an open session *named* `transient:<command>`
18+ * identically; the session label is never re-read as the discriminant. A claim that states no kind
19+ * can hold and fence a device but can never cover a command.
20+ */
21+ export type DeviceClaimOwnerKind = 'open-session' | 'transient-command' | 'unstated' ;
22+
23+ const DEVICE_CLAIM_OWNER_KINDS : readonly DeviceClaimOwnerKind [ ] = [
24+ 'open-session' ,
25+ 'transient-command' ,
26+ 'unstated' ,
27+ ] ;
28+
1429/**
1530 * A claim owned by one process: the session or the sessionless command that took it. Its principal
1631 * is `ownerPid`/`ownerStartTime`/`ownerToken`, which is what every clearing surface matches on.
@@ -28,13 +43,13 @@ export type DeviceClaim = {
2843 createdAtMs : number ;
2944 updatedAtMs : number ;
3045 /**
31- * Set when a `transient-exclusive` command took the claim: the command it is scoped to. This is
32- * the claim-kind discriminant (#3345) — a command-scoped claim is never an open session's claim,
33- * even when its `session` label reads `transient:<command>` and a caller copies that label into
34- * `--session`. Absent means an open session owns it, including every legacy record: a v1/v2
35- * record cannot prove which it was, and no kind decision is ever taken on one (see
36- * {@link decodeStoredDeviceClaim}).
46+ * The persisted claim-kind discriminant (#3345). Coverage reads this and never the `session`
47+ * label: an open session may legitimately be named `transient:<command>`, and any printed
48+ * address can be pasted back into `--session`. It is stored rather than derived from the schema
49+ * version so a record renewed or abandoned after migration keeps saying the same thing.
3750 */
51+ claimOwnerKind : DeviceClaimOwnerKind ;
52+ /** The command a `transient-command` claim is scoped to; present for exactly that kind. */
3853 transientCommand ?: string ;
3954 /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */
4055 abandonedAtMs ?: number ;
@@ -62,50 +77,53 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners
6277}
6378
6479/**
65- * Legacy (v1/v2) records decode kindless — as open-session claims — because they cannot state a
66- * kind: released v1/v2 recorded a command-scoped claim and an open session *named*
67- * `transient:<command>` identically, so the label is no proof of kind. Decoding kindless is safe
68- * by construction, not by guess: every consumer of a claim's kind first requires
69- * `isClaimOwnedByThisDaemon` (same stateDir, pid, and process start time), and a current process
70- * writes only v3 records whose kind is stored — a legacy record can only be one this process
71- * could not have written, so its owner identity is proven not to match. Recovery for a legacy
72- * record needs nothing special: the recording process still holds its token, and any other owner
73- * is settled or conflicted by the principal-based paths, none of which reads a kind.
80+ * Every stored record normalizes to the current schema so that a renewal or an abandon can write a
81+ * decoded claim back without changing its shape. A v1/v2 record has no kind field, and the session
82+ * label cannot supply one, so it decodes as `unstated`: it keeps its full principal for clearing,
83+ * reconciliation, and stale settlement, and states no ownership kind.
7484 */
7585export function decodeStoredDeviceClaim ( value : unknown ) : DeviceClaim | null {
7686 if ( ! isRecord ( value ) ) return null ;
7787 if ( value . schemaVersion === DEVICE_CLAIM_SCHEMA_VERSION ) return decodeCurrentClaim ( value ) ;
78- if ( value . schemaVersion === 2 ) return decodeClaimBody ( value ) ;
88+ if ( value . schemaVersion === 2 ) return decodeClaimBody ( value , 'unstated' ) ;
7989 if ( value . schemaVersion === 1 ) return migrateLegacyClaim ( value ) ;
8090 return null ;
8191}
8292
8393/**
84- * The current schema states its claim kind explicitly: a missing `transientCommand` is an
85- * open-session claim, never a pattern re-derived from the session label.
94+ * A current record states its kind, and the two kind fields have to agree: a command claim names
95+ * its command, any other kind names none. Anything else fails the record closed rather than
96+ * guessing which owner a session label suggests.
8697 */
8798function decodeCurrentClaim ( raw : Record < string , unknown > ) : DeviceClaim | null {
88- const claim = decodeClaimBody ( raw ) ;
89- if ( ! claim ) return null ;
90- if ( raw . transientCommand === undefined ) return claim ;
91- return isNonEmptyString ( raw . transientCommand )
92- ? { ...claim , transientCommand : raw . transientCommand }
93- : null ;
99+ const { claimOwnerKind, transientCommand } = raw ;
100+ if ( ! isKnownOwnerKind ( claimOwnerKind ) ) return null ;
101+ if ( claimOwnerKind === 'transient-command' ) {
102+ if ( ! isNonEmptyString ( transientCommand ) ) return null ;
103+ const claim = decodeClaimBody ( raw , claimOwnerKind ) ;
104+ return claim ? { ...claim , transientCommand } : null ;
105+ }
106+ if ( transientCommand !== undefined ) return null ;
107+ return decodeClaimBody ( raw , claimOwnerKind ) ;
94108}
95109
96- type DecodedClaimBody = Omit < DeviceClaim , 'transientCommand' | 'abandonedAtMs' > & {
97- schemaVersion : 3 ;
98- abandonedAtMs ?: number ;
99- } ;
110+ function isKnownOwnerKind ( value : unknown ) : value is DeviceClaimOwnerKind {
111+ return (
112+ typeof value === 'string' && ( DEVICE_CLAIM_OWNER_KINDS as readonly string [ ] ) . includes ( value )
113+ ) ;
114+ }
100115
101- function decodeClaimBody ( raw : Record < string , unknown > ) : DecodedClaimBody | null {
116+ function decodeClaimBody (
117+ raw : Record < string , unknown > ,
118+ claimOwnerKind : DeviceClaimOwnerKind ,
119+ ) : DeviceClaim | null {
102120 if ( ! isRecord ( raw . device ) || ! isNonEmptyString ( raw . device . name ) ) return null ;
103121 const identity = decodeDeviceIdentity ( raw . device ) ;
104- return identity ? decodeClaimFields ( raw , identity , raw . device . name ) : null ;
122+ return identity ? decodeClaimFields ( raw , identity , raw . device . name , claimOwnerKind ) : null ;
105123}
106124
107- /** Released schema-v1 advisory claims are normalized into the canonical v3 model on read . */
108- function migrateLegacyClaim ( raw : Record < string , unknown > ) : DecodedClaimBody | null {
125+ /** Released schema-v1 claims differ only in device shape; they state no claim kind . */
126+ function migrateLegacyClaim ( raw : Record < string , unknown > ) : DeviceClaim | null {
109127 if ( ! isRecord ( raw . device ) ) return null ;
110128 const legacy = raw . device ;
111129 const name = legacy . name ;
@@ -126,14 +144,15 @@ function migrateLegacyClaim(raw: Record<string, unknown>): DecodedClaimBody | nu
126144 ? { }
127145 : { iosPhysicalDeviceBackend : legacy . iosPhysicalDeviceBackend } ) ,
128146 } ) ;
129- return identity ? decodeClaimFields ( raw , identity , name ) : null ;
147+ return identity ? decodeClaimFields ( raw , identity , name , 'unstated' ) : null ;
130148}
131149
132150function decodeClaimFields (
133151 raw : Record < string , unknown > ,
134152 identity : DeviceIdentity ,
135153 name : string ,
136- ) : DecodedClaimBody | null {
154+ claimOwnerKind : DeviceClaimOwnerKind ,
155+ ) : DeviceClaim | null {
137156 const location = decodeClaimLocation ( raw ) ;
138157 const owner = decodeClaimOwner ( raw ) ;
139158 const timestamps = decodeClaimTimestamps ( raw ) ;
@@ -145,13 +164,13 @@ function decodeClaimFields(
145164 device : { ...identity , name } ,
146165 ...owner ,
147166 ...timestamps ,
167+ claimOwnerKind,
148168 } ;
149169}
150170
151171/**
152172 * The claim `session` recorded for a command-scoped claim. Lives with the record so the label and
153- * its inverse stay one owner; admission decides on {@link DeviceClaim.transientCommand}, never on
154- * this shape.
173+ * its inverse stay one owner; `claimOwnerKind` is what decides a kind, never this shape.
155174 */
156175export function transientDeviceClaimSession ( command : string ) : string {
157176 return `${ TRANSIENT_CLAIM_LABEL_PREFIX } ${ command } ` ;
0 commit comments