code wiki / _hdl_build / nx_share_plane.nx

nx_share_plane.nx source

↩ module page · 276 lines · 12250 B

1// ⚠⚠ SUPERSEDED 2026-07-25 -- DO NOT BUILD ON THIS. Use `nx_suite_share.nx`. 2// 3// This organ was a SECOND implementation of a rule the estate already had. `runtime/nx_rebac.nx` is the one 4// authorization plane -- a sovereign Zanzibar/ReBAC tuple model, deny-by-default, additive tombstones with 5// latest-wins -- and its own header says it exists because the ecosystem once carried three incompatible 6// authz philosophies. This file was quietly becoming the fourth. 7// 8// It was retired the way the estate requires: by PROOF, not assertion. 9// * `nx_share_convergence_gate` ran the same nine scenarios through both planes: 7 AGREE, 2 declared 10// divergences, 0 silent ones -- so the grant/revoke/check half was measurably redundant. 11// * `nx_suite_share` now speaks the suite's vocabulary over nx_rebac, and `nx_suite_share_gate` re-aims 12// all fifteen of the attacks below at it: 15/15 GREEN. Nothing this file protected went unprotected. 13// * The two divergences became work rather than losses: claim-before-share is kept as a facade-level 14// tightening, and delegated re-sharing moved onto the shared plane as a rewrite-table ROW (the new 15// `album` object type with its `curator` relation) instead of a capability bit only this file knew about. 16// KEPT, not deleted, because the convergence gate imports it -- the evidence for the retirement has to keep 17// compiling, or the retirement becomes a claim again. That gate is its only remaining consumer. 18// 19// ---- original header, for the record ---- 20// nx_share_plane.nx -- THE SHARE PLANE. One identity-keyed answer to "who may see what", so that shared 21// gallery, shared contact directory and digital gifts are three thin surfaces over one rule instead of 22// three copies of it. 23// 24// WHY THIS IS RUNG 1 AND NOT A SHARED-GALLERY PAGE. The ruler's gap_queue put D2 (shared gallery), E3 25// (shared contact info) and F1 (digital gifts) in its top four, all at rank 9. Three of the top four are 26// the SAME primitive wearing different clothes. Building them one page at a time would produce three 27// who-may-see-what implementations that drift apart, and the one that drifts is a privacy bug. So the 28// measurement chose the rung; the composition is just rule 15 applied to what the measurement found. 29// 30// SHAPE: a caller-supplied arena, no syscalls, no main -> the live daemon composes it per request AND a 31// gate imports it standalone, exactly like nx_connect_ui. The log is APPEND-ONLY (rule 13): revoking 32// appends a revocation, it never erases the grant that existed. History is what makes "who could see this 33// last March" answerable, and a sharing plane that cannot answer that is not auditable. 34// 35// THE INVARIANTS (each one is a gate tooth, not a promise in a comment): 36// * DENY BY DEFAULT -- no matching live grant means no. 37// * ONLY THE OWNER GRANTS -- a subject cannot grant themselves, and holding VIEW never lets you pass 38// VIEW to someone else. No transitive escalation without an explicit ADMIN bit. 39// * REVOCATION IS TOTAL AND IMMEDIATE -- after a revoke every capability answers no, on the next call. 40// * SELF-GRANT IS REFUSED -- an owner already has their own resource; letting a self-grant exist would 41// let an owner revoke themselves out of their own album. 42// * A GIFT IS A TRANSFER OF OWNERSHIP, NOT A COPY -- after a gift the giver is no longer the owner. That 43// is the honest difference between giving something and sharing it, and the ledger keeps both names. 44// * BOUNDED AND LOUD -- past capacity it REFUSES. A plane that silently drops the last grant is a plane 45// that silently grants access it should have refused. 46// license_tier: ORIGINAL 47 48// record layout, 8 slots per row 49const SP_KIND: i64 = 0 50const SP_OWNER: i64 = 1 51const SP_SUBJ: i64 = 2 52const SP_CLASS: i64 = 3 53const SP_RID: i64 = 4 54const SP_CAP: i64 = 5 55const SP_STATE: i64 = 6 56const SP_SEQ: i64 = 7 57const SP_WIDTH: i64 = 8 58// arena header 59const SP_H_N: i64 = 0 60const SP_H_CAP: i64 = 1 61const SP_H_SEQ: i64 = 2 62const SP_HDR: i64 = 4 63 64const SP_K_GRANT: i64 = 1 65const SP_K_OWN: i64 = 2 66 67const SP_ST_LIVE: i64 = 1 68const SP_ST_REVOKED: i64 = 0 69 70// capability bits. VIEW is the floor; ADMIN is the only bit that lets a non-owner re-share. 71const SP_VIEW: i64 = 1 72const SP_ADD: i64 = 2 73const SP_ADMIN: i64 = 4 74const SP_CAPMAX: i64 = 7 75 76// resource classes -- the three surfaces the ruler's queue named, plus calendar for the rung after 77const SP_C_ALBUM: i64 = 1 78const SP_C_CONTACT: i64 = 2 79const SP_C_GIFT: i64 = 3 80const SP_C_CALENDAR: i64 = 4 81 82// errors, all negative and all distinct so a caller can render WHY rather than "it did nothing" 83const SP_E_FULL: i64 = -2 84const SP_E_NOTOWNER: i64 = -1 85const SP_E_SELF: i64 = -3 86const SP_E_BADCAP: i64 = -4 87const SP_E_NOOWNER: i64 = -5 88const SP_E_BADARG: i64 = -6 89 90func sp_init(a: *i64, cap: i64) -> i64 { 91 if cap <= 0 { return SP_E_BADARG } 92 a[SP_H_N] = 0 93 a[SP_H_CAP] = cap 94 a[SP_H_SEQ] = 0 95 return 0 96} 97func sp_count(a: *i64) -> i64 { return a[SP_H_N] } 98func sp_slots_needed(cap: i64) -> i64 { return SP_HDR + cap*SP_WIDTH } 99func sp_at(a: *i64, i: i64, field: i64) -> i64 { return a[SP_HDR + i*SP_WIDTH + field] } 100 101func sp_append(a: *i64, kind: i64, owner: i64, subj: i64, rclass: i64, rid: i64, cap: i64, state: i64) -> i64 { 102 let n: i64 = a[SP_H_N] 103 if n >= a[SP_H_CAP] { return SP_E_FULL } 104 let b: i64 = SP_HDR + n*SP_WIDTH 105 a[SP_H_SEQ] = a[SP_H_SEQ] + 1 106 a[b+SP_KIND] = kind 107 a[b+SP_OWNER] = owner 108 a[b+SP_SUBJ] = subj 109 a[b+SP_CLASS] = rclass 110 a[b+SP_RID] = rid 111 a[b+SP_CAP] = cap 112 a[b+SP_STATE] = state 113 a[b+SP_SEQ] = a[SP_H_SEQ] 114 a[SP_H_N] = n + 1 115 return a[SP_H_SEQ] 116} 117 118// current owner of a resource: the LATEST ownership record wins, so a gift chain resolves to its end. 119// 0 means nobody has claimed it on this plane. 120func sp_owner_of(a: *i64, rclass: i64, rid: i64) -> i64 { 121 var found: i64 = 0 122 var i: i64 = 0 123 let n: i64 = a[SP_H_N] 124 while i < n { 125 if sp_at(a,i,SP_KIND)==SP_K_OWN { 126 if sp_at(a,i,SP_CLASS)==rclass { 127 if sp_at(a,i,SP_RID)==rid { found = sp_at(a,i,SP_SUBJ) } 128 } 129 } 130 i = i + 1 131 } 132 return found 133} 134func sp_claim(a: *i64, owner: i64, rclass: i64, rid: i64) -> i64 { 135 if owner<=0 { return SP_E_BADARG } 136 let cur: i64 = sp_owner_of(a, rclass, rid) 137 // claiming is idempotent (rule 10): re-claiming what you already own appends nothing 138 if cur==owner { return 0 } 139 if cur!=0 { return SP_E_NOTOWNER } 140 return sp_append(a, SP_K_OWN, owner, owner, rclass, rid, SP_CAPMAX, SP_ST_LIVE) 141} 142 143// the LIVE grant for (subject, resource): latest record wins, so grant -> revoke -> grant resolves to the 144// last thing that actually happened rather than to whichever row was scanned first. 145func sp_live_cap(a: *i64, subj: i64, rclass: i64, rid: i64) -> i64 { 146 var capbits: i64 = 0 147 var i: i64 = 0 148 let n: i64 = a[SP_H_N] 149 while i < n { 150 if sp_at(a,i,SP_KIND)==SP_K_GRANT { 151 if sp_at(a,i,SP_SUBJ)==subj { 152 if sp_at(a,i,SP_CLASS)==rclass { 153 if sp_at(a,i,SP_RID)==rid { 154 if sp_at(a,i,SP_STATE)==SP_ST_LIVE { capbits = sp_at(a,i,SP_CAP) } 155 else { capbits = 0 } 156 } 157 } 158 } 159 } 160 i = i + 1 161 } 162 return capbits 163} 164 165// May `actor` grant on this resource? Only the owner, or a holder of an explicit ADMIN grant. VIEW never 166// implies the right to hand VIEW onward -- that is the whole difference between reading and re-sharing. 167func sp_may_grant(a: *i64, actor: i64, rclass: i64, rid: i64) -> i64 { 168 let own: i64 = sp_owner_of(a, rclass, rid) 169 if own==0 { return 0 } 170 if own==actor { return 1 } 171 let held: i64 = sp_live_cap(a, actor, rclass, rid) 172 if (held & SP_ADMIN) == SP_ADMIN { return 1 } 173 return 0 174} 175 176func sp_grant(a: *i64, actor: i64, subj: i64, rclass: i64, rid: i64, capbits: i64) -> i64 { 177 if subj<=0 { return SP_E_BADARG } 178 if capbits<=0 { return SP_E_BADCAP } 179 if capbits>SP_CAPMAX { return SP_E_BADCAP } 180 let own: i64 = sp_owner_of(a, rclass, rid) 181 if own==0 { return SP_E_NOOWNER } 182 if subj==own { return SP_E_SELF } 183 if sp_may_grant(a, actor, rclass, rid)==0 { return SP_E_NOTOWNER } 184 return sp_append(a, SP_K_GRANT, own, subj, rclass, rid, capbits, SP_ST_LIVE) 185} 186 187func sp_revoke(a: *i64, actor: i64, subj: i64, rclass: i64, rid: i64) -> i64 { 188 let own: i64 = sp_owner_of(a, rclass, rid) 189 if own==0 { return SP_E_NOOWNER } 190 if sp_may_grant(a, actor, rclass, rid)==0 { return SP_E_NOTOWNER } 191 return sp_append(a, SP_K_GRANT, own, subj, rclass, rid, 0, SP_ST_REVOKED) 192} 193 194// THE READ PATH. Everything above exists so that this one answer is the same everywhere. 195// SUBSET TEST, not a single-bit test. The first version divided by `want` and read the low bit, which is 196// only correct when `want` is one power of two -- a caller asking "may they VIEW *and* ADD" (want=3) would 197// have got an arithmetic accident for an answer. On a permission check an accident is an access-control 198// bug, so this asks the only question that is always right: are ALL the requested bits held? 199func sp_may(a: *i64, actor: i64, rclass: i64, rid: i64, want: i64) -> i64 { 200 if want<=0 { return 0 } 201 if want>SP_CAPMAX { return 0 } 202 let own: i64 = sp_owner_of(a, rclass, rid) 203 if own==0 { return 0 } 204 if own==actor { return 1 } 205 let held: i64 = sp_live_cap(a, actor, rclass, rid) 206 if (held & want) != want { return 0 } 207 return 1 208} 209 210// A GIFT. Ownership moves; the giver stops being the owner. The ledger keeps every hop, so provenance is 211// recoverable ("who gave this to whom, and when") without any record ever being rewritten. 212func sp_gift(a: *i64, giver: i64, receiver: i64, rclass: i64, rid: i64) -> i64 { 213 if receiver<=0 { return SP_E_BADARG } 214 if giver==receiver { return SP_E_SELF } 215 let own: i64 = sp_owner_of(a, rclass, rid) 216 if own==0 { return SP_E_NOOWNER } 217 if own!=giver { return SP_E_NOTOWNER } 218 return sp_append(a, SP_K_OWN, giver, receiver, rclass, rid, SP_CAPMAX, SP_ST_LIVE) 219} 220// how many hands has this passed through? (ownership records for the resource) 221func sp_provenance_len(a: *i64, rclass: i64, rid: i64) -> i64 { 222 var c: i64 = 0 223 var i: i64 = 0 224 let n: i64 = a[SP_H_N] 225 while i < n { 226 if sp_at(a,i,SP_KIND)==SP_K_OWN { 227 if sp_at(a,i,SP_CLASS)==rclass { if sp_at(a,i,SP_RID)==rid { c = c + 1 } } 228 } 229 i = i + 1 230 } 231 return c 232} 233// the nth holder in the chain (0 = first claimant), or 0 if out of range -- this is what a gift card's 234// "given by" line renders from 235func sp_provenance_at(a: *i64, rclass: i64, rid: i64, idx: i64) -> i64 { 236 var c: i64 = 0 237 var i: i64 = 0 238 let n: i64 = a[SP_H_N] 239 while i < n { 240 if sp_at(a,i,SP_KIND)==SP_K_OWN { 241 if sp_at(a,i,SP_CLASS)==rclass { 242 if sp_at(a,i,SP_RID)==rid { 243 if c==idx { return sp_at(a,i,SP_SUBJ) } 244 c = c + 1 245 } 246 } 247 } 248 i = i + 1 249 } 250 return 0 251} 252// everyone who can currently see this resource, excluding the owner. Written for the UI panel that shows a 253// person WHO CAN SEE THIS -- a sharing rule nobody can inspect is one nobody can trust. 254func sp_viewers(a: *i64, rclass: i64, rid: i64, out: *i64, outcap: i64) -> i64 { 255 var k: i64 = 0 256 var i: i64 = 0 257 let n: i64 = a[SP_H_N] 258 while i < n { 259 if sp_at(a,i,SP_KIND)==SP_K_GRANT { 260 if sp_at(a,i,SP_CLASS)==rclass { 261 if sp_at(a,i,SP_RID)==rid { 262 let s: i64 = sp_at(a,i,SP_SUBJ) 263 // recompute from the log rather than trusting this row: the latest record for s wins 264 if sp_live_cap(a, s, rclass, rid) > 0 { 265 var seen: i64 = 0 266 var j: i64 = 0 267 while j < k { if out[j]==s { seen=1 } j=j+1 } 268 if seen==0 { if k<outcap { out[k]=s; k=k+1 } } 269 } 270 } 271 } 272 } 273 i = i + 1 274 } 275 return k 276}