@@ -8,15 +8,30 @@ 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 records migrate to it on read. */
12- export const DEVICE_CLAIM_SCHEMA_VERSION = 2 ;
11+ /** Schema version of a process-owned claim record; v1/v2 records normalize to it on read. */
12+ export const DEVICE_CLAIM_SCHEMA_VERSION = 3 ;
13+
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+ ] ;
1328
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.
1732 */
1833export type DeviceClaim = {
19- schemaVersion : 2 ;
34+ schemaVersion : 3 ;
2035 deviceKey : string ;
2136 device : DeviceIdentity & { name : string } ;
2237 session : string ;
@@ -27,6 +42,15 @@ export type DeviceClaim = {
2742 ownerToken : string ;
2843 createdAtMs : number ;
2944 updatedAtMs : number ;
45+ /**
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.
50+ */
51+ claimOwnerKind : DeviceClaimOwnerKind ;
52+ /** The command a `transient-command` claim is scoped to; present for exactly that kind. */
53+ transientCommand ?: string ;
3054 /** Set by `abandonDeviceClaim`; absent while the claim still holds the device for its owner. */
3155 abandonedAtMs ?: number ;
3256} ;
@@ -52,20 +76,53 @@ export function ownershipFromClaim(claim: DeviceClaim): DeviceClaimSessionOwners
5276 } ;
5377}
5478
79+ /**
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.
84+ */
5585export function decodeStoredDeviceClaim ( value : unknown ) : DeviceClaim | null {
5686 if ( ! isRecord ( value ) ) return null ;
5787 if ( value . schemaVersion === DEVICE_CLAIM_SCHEMA_VERSION ) return decodeCurrentClaim ( value ) ;
88+ if ( value . schemaVersion === 2 ) return decodeClaimBody ( value , 'unstated' ) ;
5889 if ( value . schemaVersion === 1 ) return migrateLegacyClaim ( value ) ;
5990 return null ;
6091}
6192
93+ /**
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.
97+ */
6298function decodeCurrentClaim ( raw : Record < string , unknown > ) : DeviceClaim | 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 ) ;
108+ }
109+
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+ }
115+
116+ function decodeClaimBody (
117+ raw : Record < string , unknown > ,
118+ claimOwnerKind : DeviceClaimOwnerKind ,
119+ ) : DeviceClaim | null {
63120 if ( ! isRecord ( raw . device ) || ! isNonEmptyString ( raw . device . name ) ) return null ;
64121 const identity = decodeDeviceIdentity ( raw . device ) ;
65- return identity ? buildDecodedClaim ( raw , identity , raw . device . name ) : null ;
122+ return identity ? decodeClaimFields ( raw , identity , raw . device . name , claimOwnerKind ) : null ;
66123}
67124
68- /** Released schema-v1 advisory claims are normalized into the canonical v2 model on read . */
125+ /** Released schema-v1 claims differ only in device shape; they state no claim kind . */
69126function migrateLegacyClaim ( raw : Record < string , unknown > ) : DeviceClaim | null {
70127 if ( ! isRecord ( raw . device ) ) return null ;
71128 const legacy = raw . device ;
@@ -87,13 +144,14 @@ function migrateLegacyClaim(raw: Record<string, unknown>): DeviceClaim | null {
87144 ? { }
88145 : { iosPhysicalDeviceBackend : legacy . iosPhysicalDeviceBackend } ) ,
89146 } ) ;
90- return identity ? buildDecodedClaim ( raw , identity , name ) : null ;
147+ return identity ? decodeClaimFields ( raw , identity , name , 'unstated' ) : null ;
91148}
92149
93- function buildDecodedClaim (
150+ function decodeClaimFields (
94151 raw : Record < string , unknown > ,
95152 identity : DeviceIdentity ,
96153 name : string ,
154+ claimOwnerKind : DeviceClaimOwnerKind ,
97155) : DeviceClaim | null {
98156 const location = decodeClaimLocation ( raw ) ;
99157 const owner = decodeClaimOwner ( raw ) ;
@@ -106,9 +164,20 @@ function buildDecodedClaim(
106164 device : { ...identity , name } ,
107165 ...owner ,
108166 ...timestamps ,
167+ claimOwnerKind,
109168 } ;
110169}
111170
171+ /**
172+ * The claim `session` recorded for a command-scoped claim. Lives with the record so the label and
173+ * its inverse stay one owner; `claimOwnerKind` is what decides a kind, never this shape.
174+ */
175+ export function transientDeviceClaimSession ( command : string ) : string {
176+ return `${ TRANSIENT_CLAIM_LABEL_PREFIX } ${ command } ` ;
177+ }
178+
179+ const TRANSIENT_CLAIM_LABEL_PREFIX = 'transient:' ;
180+
112181function decodeClaimLocation (
113182 raw : Record < string , unknown > ,
114183) : Pick < DeviceClaim , 'deviceKey' | 'session' | 'workspace' | 'stateDir' > | null {
0 commit comments