fsanyoto commited on
Commit
0b66db6
Β·
verified Β·
1 Parent(s): 9411b94

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
Files changed (4) hide show
  1. RELEASES.json +7 -1
  2. VERSION +1 -1
  3. api/routes_shares.py +789 -627
  4. platform/core/shares.py +560 -414
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "240caca",
3
  "releases": [
 
 
 
 
 
 
4
  {
5
  "version": "v52",
6
  "sha": "8263493",
 
1
  {
2
+ "current": "v53 (89ed2cb)",
3
  "releases": [
4
+ {
5
+ "version": "v53",
6
+ "sha": "89ed2cb",
7
+ "date": "2026-08-24",
8
+ "subject": "release v53"
9
+ },
10
  {
11
  "version": "v52",
12
  "sha": "8263493",
VERSION CHANGED
@@ -1 +1 @@
1
- 240caca
 
1
+ v53 (89ed2cb)
api/routes_shares.py CHANGED
@@ -1,627 +1,789 @@
1
- """routes_shares.py β€” the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
2
-
3
- GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
4
- PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
5
- GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
6
-
7
- `kind` ∈ view | folder | database | field. Roles are `view` | `edit` β€” the same two words the
8
- view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
9
- vocabulary in the UI (R10: "the same picker views use").
10
-
11
- ⭐⭐ **W38-T16 β€” `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
12
- (`shares.field_oid`). A bare column key repeats across databases β€” `notes` exists on a dozen β€”
13
- so a grant stored under one would admit the grantee to every `notes` column in the tenant at
14
- once. β›” THREE functions in this file branch on kind and ALL THREE need the new one, which is not
15
- obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
16
- is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
17
- `_notify_new_grantees` returns early, and the grantee is **granted and never told** β€” owner item
18
- 18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
19
- anything that is not a view.
20
-
21
- ⭐⭐ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β€” OWNER RULING R4, BUILT AS W40-T02** (instruction
22
- 4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
23
- owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
24
- and not in the client:
25
-
26
- 1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
27
- `database` and `field` still require the owner or an admin, and the paragraph below is why
28
- the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
29
- whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
30
- 2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
31
- `edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
32
- ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
33
- it) β€” never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
34
- therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
35
- `edit` stays the owner's alone.
36
- 3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
37
- straight through (`put_share` below, stated rather than incidental).
38
-
39
- β›” **SO OF THE TWO FEARS THE OLD SENTENCE NAMED, ONE IS ANSWERED AND ONE IS PRICED.** *"Grant
40
- themselves ownership and lock you out"* is closed by (3) and by `set_grants`' sticky owner: there
41
- is no path here by which a grantee becomes an owner. *"Widen a users-scoped view to everyone"* is
42
- NOT closed β€” an `edit` grantee may add `*` β€” it is CAPPED by (2) at `Can view`, on a surface where
43
- `require_session` plus the topic's own gate plus the receiver's row scope all still run first. That
44
- is the capability R4 asked for, stated as a cost rather than left as a surprise. A `view` grantee
45
- still cannot share at all, and still gets `403`. The client greys the editor for non-administrators
46
- and offers "Can edit" to anyone it greys in; that is a courtesy, and these checks are the wall.
47
-
48
- ⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL β€” ON A GOVERNED MODULE.** `*` ("everyone") means
49
- every account that can already open the surface: `require_session` plus the topic's own gate run
50
- first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
51
- closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
52
- could already reach the data ([[aios-permissioning]]).
53
-
54
- β›”β›” **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
55
- MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
56
- and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
57
- DECLARED for a `ut_*` database** β€” `routes_tables.py` makes zero `perm_scope` calls and passes
58
- `hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
59
- by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β€” every row, every
60
- column β€” and an `*` database grant admits every account in the tenant to all of it.
61
- That is a real capability, deliberately kept; what was wrong was a docstring promising a second
62
- wall that does not exist for this kind. Scoping user tables is booked, not done
63
- (`waves/wave32/sharing-audit.md` S-8).
64
-
65
- ⚠ **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
66
- registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
67
- β€” the view's own `permissions` β€” is what actually decides who may OPEN a view.** A grant here
68
- whose object is invisible under that one is a row in a list that opens a refusal, which is what
69
- made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
70
- the two vocabularies are still two.
71
- """
72
- from fastapi import APIRouter, Body, Depends
73
-
74
- import core.shares as shares
75
- import core.users as users
76
- from deps import Session, err, require_session
77
- # ⭐ W32-T28 (C3) β€” the SHARE notification's topic word, imported from the module that CLASSIFIES
78
- # it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
79
- # reader agreeing about one string is the whole difference between an Inbox row that opens the
80
- # shared database and one that is quietly unclickable.
81
- from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
82
-
83
- router = APIRouter(prefix="/api/v1")
84
-
85
-
86
- def _kind_or_400(raw):
87
- try:
88
- return shares._check_kind(raw)
89
- except ValueError as e:
90
- raise err(400, "bad_kind", str(e))
91
-
92
-
93
- # ── ⭐⭐ WAVE 32 Β· T26 (owner item 18, ruling R12) β€” THE WALL THIS FILE SAID IT HAD ─────────────
94
- #
95
- # `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
96
- # means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
97
- # strings off the URL and the only dependency was `require_session`, so any signed-in account
98
- # could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
99
- # an object with no grant record skipped the check entirely and the caller was stamped OWNER β€”
100
- # sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
101
- # argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
102
- #
103
- # ⚠ AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
104
- # "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
105
- # victim next opens the dialog.
106
-
107
- #: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
108
- #: route is not told which topic β€” so resolving one means asking each.
109
- _BUILTIN_TOPICS = ("customer", "product")
110
-
111
-
112
- def _field_storage_keys(table_key):
113
- """Resolve the client-facing field topic to its durable stores and grant topic."""
114
- raw = str(table_key or "").strip()
115
- if raw in ("customer_data", "customer_table_workspace"):
116
- return "customer_table_workspace", "customer_table_workspace", "customer_data"
117
- if raw in ("product_data", "product_table_workspace"):
118
- return "product_table_workspace", "product_table_workspace", "product_data"
119
- if raw.startswith("ut_"):
120
- bare = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
121
- return f"{bare}_table_workspace", bare, bare
122
- workspace = raw if raw.endswith("_table_workspace") else f"{raw}_table_workspace"
123
- shared = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
124
- return workspace, shared, shared
125
-
126
-
127
- def _field_definition(session, table_key, field_key):
128
- """Return the shared/private definition and the keys used by its write paths."""
129
- workspace_key, shared_key, grant_topic = _field_storage_keys(table_key)
130
- try:
131
- from core import shared_overlay
132
- shared = (shared_overlay.fields(shared_key, st=session.runtime) or {}).get(field_key)
133
- if isinstance(shared, dict):
134
- return shared, True, workspace_key, shared_key, grant_topic
135
- import core.table_store as table_store
136
- private = (table_store.make(workspace_key, st=session.runtime)
137
- .workspace(session.uname).get("fields") or {}).get(field_key)
138
- if isinstance(private, dict):
139
- return private, False, workspace_key, shared_key, grant_topic
140
- except Exception: # noqa: BLE001
141
- pass
142
- return None, False, workspace_key, shared_key, grant_topic
143
-
144
-
145
- def _field_owner(session, definition, already_shared):
146
- """Resolve the creator for the share claim wall.
147
-
148
- A private field is already namespaced by the caller's own workspace. Older field records
149
- from before the host-side creator stamp therefore remain safely claimable by that workspace
150
- owner, while a shared definition with no creator stays admin-only because its storage is
151
- tenant-wide and cannot identify an owner from residency alone.
152
- """
153
- owner = str((definition or {}).get("createdBy") or "").strip()
154
- if owner:
155
- return owner
156
- return str(session.uname or "").strip() if not already_shared else ""
157
-
158
-
159
- def _topics(session):
160
- """Every topic whose workspace could hold a view or folder for this tenant.
161
-
162
- ⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
163
- tenant #0) to answer a question about KEYS (D-185).
164
- """
165
- try:
166
- import core.user_tables as ut
167
- return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
168
- except Exception: # noqa: BLE001
169
- return _BUILTIN_TOPICS
170
-
171
-
172
- def _owns_object(session, kind, oid):
173
- """May this caller CLAIM an object that has no grant record yet β€” i.e. do they own it?
174
-
175
- β›” THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
176
- each topic's workspace in turn, which is N store reads; making every share call pay that
177
- would put a loop on a route the manage-access dialog opens. The dangerous path is the one
178
- where a caller is about to be stamped OWNER of something nobody owns β€” so the resolution runs
179
- exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
180
- """
181
- if session.admin:
182
- return True
183
- if kind == "field":
184
- # ⭐⭐ W38-T16 β€” A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
185
- # (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
186
- # D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
187
- # deletable by one person and shareable by another.
188
- # ⚠ THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
189
- # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
190
- # without it the column's own creator is answered `404 no_object` on the first attempt to
191
- # share the thing they just made.
192
- table_key, field_key = shares.split_field_oid(oid)
193
- if not table_key:
194
- return False
195
- defn, _shared, _workspace, _shared_key, _grant_topic = _field_definition(
196
- session, table_key, field_key)
197
- owner = _field_owner(session, defn, _shared)
198
- return bool(defn) and owner.lower() == str(session.uname).strip().lower()
199
- if kind == "database":
200
- # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
201
- # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
202
- # here would be the second definition this wave keeps finding.
203
- try:
204
- import core.user_tables as ut
205
- return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
206
- st=session.runtime))
207
- except Exception: # noqa: BLE001
208
- return False
209
- try:
210
- import core.table_store as table_store
211
- except Exception: # noqa: BLE001
212
- return False
213
- for topic in _topics(session):
214
- try:
215
- ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
216
- hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
217
- except Exception: # noqa: BLE001
218
- continue
219
- if hit:
220
- # `find_view`/`find_folder` answer `(owner_username, …)`. The claim belongs to the
221
- # person whose personal stratum holds it β€” anybody else reaching this line is
222
- # exactly the case S-1 describes.
223
- return str(hit[0]) == str(session.uname)
224
- return False
225
-
226
-
227
- def _can_see_object(session, kind, oid):
228
- """May this caller READ an object's grant list β€” i.e. can they reach the object at all?
229
-
230
- β›”β›” THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
231
- REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
232
- ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
233
- PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
234
- `permissions.edit = "collaborative"` and no grant record yet β€” a view bob **can open and edit
235
- in the grid** β€” answered `404` when bob opened its manage-access dialog. Measured before
236
- fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
237
- `404 no_object`.
238
- ⚠ THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
239
- and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
240
- `permissions` (system B) β€” which is the one that actually decides who may OPEN it.
241
- ⚠ And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
242
- gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β€”
243
- 'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
244
- legitimate collaborator their view does not exist.
245
-
246
- β›” THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
247
- owner β€” that is S-1, and widening this predicate onto `put_share` would re-open it.
248
- """
249
- if _owns_object(session, kind, oid):
250
- return True
251
- if kind != "view":
252
- # A folder carries no per-object visibility flag of its own, and a database's `may_open`
253
- # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
254
- # ⭐ W38-T16 β€” AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
255
- # `get_share` tests `role is None` first and a grant answers a role, so the only caller
256
- # left is an account with no relationship to the column at all. Widening it would let any
257
- # signed-in session enumerate who holds which column on a database they cannot open.
258
- return False
259
- try:
260
- import core.table_store as table_store
261
- for topic in _topics(session):
262
- hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
263
- if hit:
264
- return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
265
- session.uname, is_admin=session.admin))
266
- except Exception: # noqa: BLE001
267
- return False
268
- return False
269
-
270
-
271
- def _entries_or_400(session, entries):
272
- """Validate a grant list against the tenant's REAL, ACTIVE accounts β€” and refuse BY NAME.
273
-
274
- β›” `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
275
- a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
276
- of a string and the role word β€” never that the user EXISTS, is ACTIVE, or is in this tenant**,
277
- so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
278
- sharer believes the person has access. That is item 18's plain reading.
279
- ⚠ The correct population is computed THREE FUNCTIONS BELOW and served to the picker
280
- (`_people`). One route, two populations, and the write door was the permissive one.
281
- ⚠ `*` (everyone) is not a user and is admitted deliberately β€” it is R10's vocabulary for
282
- "every account that can already open the surface".
283
- """
284
- known = {p["username"].strip().lower() for p in _people(session.tenant)}
285
- unknown = []
286
- for e in entries or ():
287
- if not isinstance(e, dict):
288
- continue
289
- user = str(e.get("user") or "").strip().lower()
290
- if user and user != shares.EVERYONE and user not in known:
291
- unknown.append(user)
292
- if unknown:
293
- raise err(400, "unknown_people",
294
- "no active account in this workspace is named "
295
- + ", ".join(sorted(set(unknown)))
296
- + ". Nothing was shared. Pick people from the list rather than typing a name.")
297
-
298
-
299
- @router.get("/share/mine")
300
- def my_shares(session: Session = Depends(require_session)):
301
- """Everything shared WITH me, by kind β€” the "Shared with me" rail section (R10).
302
-
303
- Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
304
- in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
305
- that is not malformed at all.
306
- """
307
- return shares.shared_with(session.uname, st=session.runtime)
308
-
309
-
310
- @router.get("/share/{kind}/{oid}")
311
- def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
312
- kind = _kind_or_400(kind)
313
- if kind == "field":
314
- table_key, field_key = shares.split_field_oid(oid)
315
- if table_key and field_key:
316
- oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
317
- rec = shares.grants(kind, oid, st=session.runtime)
318
- role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
319
- may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
320
- st=session.runtime)
321
- # W39-T29 β€” the dialog opens with GET before its first PUT. Until that PUT exists the grant
322
- # registry has no owner to return from `may_administer`, even though the same caller may safely
323
- # claim their own object through the PUT path below. Reflect that exact claim predicate here:
324
- # a Member who owns an unshared View receives the people picker; a collaborator still does not.
325
- if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
326
- may_admin = True
327
- # ⭐ W32-T26 (audit S-3) β€” A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
328
- # who owns it, everyone it is granted to, and the tenant's whole username↔name directory β€”
329
- # to any signed-in session, about objects it cannot open. Now a caller with no role on an
330
- # object must prove they can reach it, and gets a 404 otherwise: the same answer a
331
- # non-existent id gives, so the route cannot be used to probe which ids are real.
332
- # ⚠ `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
333
- # caller who has no relationship with the object at all.
334
- if role is None and not _can_see_object(session, kind, oid):
335
- raise err(404, "no_object", "no such item, or it is not shared with this account")
336
- return {
337
- **rec,
338
- "role": role,
339
- "mayAdminister": may_admin,
340
- # ⚠ WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
341
- # them. `assignable_people` serves bare display names because `user`-kind CELLS store
342
- # display names β€” that list's shape cannot change without migrating cell values β€” so
343
- # this route serves objects of its own. Existing grants that were written as lowercased
344
- # display names are normalised by the wave-21 cleanup script.
345
- # ⭐ W32-T26 (audit S-3) β€” the roster is the EDITOR's data, so it rides only for a caller
346
- # who may open the editor. A read-only grantee gets the grant list (their fair question is
347
- # "who else has this?") and not a directory of every account in the workspace.
348
- # ⭐⭐ W40-T02 / R4 β€” AND THAT RULE IS WHY THIS LINE NEEDED NO EDIT. `may_administer` now
349
- # answers True for an `edit` grantee on a VIEW, which MOVES that account into "may open
350
- # the editor" β€” so the picker they need arrives by the roster riding on the same flag it
351
- # always did. Gating it on anything else (owner, `role == 'owner'`, a fresh predicate)
352
- # would be a second answer to a question this file already answers once, and would leave
353
- # the new grantee with an editor and no people to put in it. A `view` grantee is still
354
- # `may_admin=False` here and still gets `[]`.
355
- "people": _people(session.tenant) if may_admin else [],
356
- }
357
-
358
-
359
- def _people(tenant):
360
- """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
361
- BINDING identity alongside the display one."""
362
- try:
363
- reg = users.registry() or {}
364
- except Exception:
365
- return []
366
- want = str(tenant or '').strip().lower()
367
- out = []
368
- for uname, u in reg.items():
369
- if not isinstance(u, dict) or u.get('active') is False:
370
- continue
371
- if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
372
- continue
373
- out.append({"username": str(uname), "name": str(u.get('name') or uname)})
374
- return sorted(out, key=lambda p: p["name"].lower())
375
-
376
-
377
- @router.put("/share/{kind}/{oid}")
378
- def put_share(kind: str, oid: str, body: dict = Body(default=None),
379
- session: Session = Depends(require_session)):
380
- kind = _kind_or_400(kind)
381
- if kind == "field":
382
- table_key, field_key = shares.split_field_oid(oid)
383
- if table_key and field_key:
384
- oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
385
- body = body or {}
386
- rec = shares.grants(kind, oid, st=session.runtime)
387
- # `claiming` is the "no owner yet, and this caller may become one" branch, hoisted to a name
388
- # because TWO decisions below need it: the ceiling (a claimant is about to be the owner, so
389
- # their ceiling is an owner's) and the owner written back (D3 β€” see `set_grants` at the end).
390
- claiming = False
391
- # An object with NO grant record yet has no owner β€” the first person to share it claims it.
392
- # That is safe because reaching this route at all means passing the surface's own wall, and
393
- # the alternative (refusing until somebody seeds an owner) would make a brand-new folder
394
- # unshareable by the person who just made it.
395
- if rec["owner"]:
396
- if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
397
- st=session.runtime):
398
- # ⭐ R4 / W40-T02 β€” `may_administer` now also admits an `edit` grantee on a VIEW, so
399
- # the population refused here is narrower than the code word `not_owner` suggests: a
400
- # `view` grantee, or an account with an `edit` role on a kind outside
401
- # `shares.RESHARE_KINDS`. The code string is kept because clients match on it.
402
- raise err(403, "not_owner",
403
- "only the owner of this item (or an administrator) can change who it is "
404
- "shared with")
405
- # β›”β›” W32-T26 (audit S-1) β€” THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
406
- # is still claimed by the first person to share it β€” that rule is right, and refusing until
407
- # somebody seeds an owner would make a brand-new folder unshareable by the person who just
408
- # made it. What was missing is the half the old comment ASSERTED and the code never did: the
409
- # claimant has to be able to reach the object. Without this, any signed-in account could
410
- # stamp itself owner of an id it had never seen and lock the real creator out for good.
411
- elif not _owns_object(session, kind, oid):
412
- raise err(404, "no_object", "no such item, or it is not shared with this account")
413
- else:
414
- claiming = True
415
- entries = body.get("entries")
416
- if not isinstance(entries, list):
417
- raise err(400, "bad_entries",
418
- "entries must be a list of {user, role}. Send [] to un-share, which is how "
419
- "revoking is expressed")
420
- _entries_or_400(session, entries)
421
- # ⭐⭐ R4 / W40-T02 β€” THE CEILING. `may_administer` above now opens this door to an `edit`
422
- # grantee on a VIEW, so R4's other half ("a re-share may never exceed the role the re-sharer
423
- # holds") needs a check of its own: that caller may hand out `view`, and conferring `edit`
424
- # stays the owner's or an administrator's.
425
- #
426
- # β›” AFTER THE ADMISSION, NEVER BEFORE, AND THAT ORDER IS A SECURITY PROPERTY. A caller with
427
- # no role at all must keep receiving `404 no_object` (audit S-1/S-3: a stranger learns
428
- # nothing, so this route cannot be used to probe which ids are real). A ceiling raised first
429
- # would answer that caller `403` and turn the one route hardened against id-probing back into
430
- # an oracle that confirms an id exists. It also runs before the `field` promotion below, so a
431
- # refusal cannot leave a column promoted with no grant written.
432
- #
433
- # β›” AND IT IS THE DELTA, NOT EVERY ROW OF THE BODY β€” read off the shipped client, not
434
- # assumed. `ShareDialog.save` PUTs the WHOLE list every time ("a body assembled from a delta
435
- # would revoke everyone it failed to mention"), so the re-sharer's OWN `{user, role: "edit"}`
436
- # row rides in every payload they are able to produce. Refusing per-entry would `403` the
437
- # exact re-share this ticket exists to enable, and the only body that would pass is one that
438
- # revokes the re-sharer. So what is refused is edit access this caller is CREATING: a name
439
- # arriving at `edit`, or an existing `view` grantee raised to it. A row that already stood at
440
- # `edit` was the OWNER's decision, and is not this caller's to be refused for.
441
- #
442
- # ⚠ A CLAIMANT IS AN OWNER. The branch above admits a Member who owns an object that has no
443
- # grant record yet, and `set_grants` is about to stamp them owner β€” asking the registry for
444
- # their role here would answer `None` (no record exists to hold one) and refuse the first
445
- # `edit` grant on every newly created view. Same predicate as the door, one line apart.
446
- ceiling = "edit" if claiming else shares.max_grantable_role(
447
- kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
448
- if ceiling != "edit":
449
- held = {e.get("user"): e.get("role") for e in (rec["entries"] or ())}
450
- raised = set()
451
- for e in entries:
452
- if not isinstance(e, dict):
453
- continue
454
- who = str(e.get("user") or "").strip().lower()
455
- if who and str(e.get("role") or "").strip().lower() == "edit" \
456
- and held.get(who) != "edit":
457
- raised.add(who)
458
- if raised:
459
- noun = {"view": "view", "folder": "folder",
460
- "database": "database", "field": "column"}.get(kind, "item")
461
- named = ", ".join("everyone in this workspace" if w == shares.EVERYONE else w
462
- for w in sorted(raised))
463
- raise err(403, "grant_exceeds_role",
464
- "you can share this " + noun + " at Can view, which is as far as your own "
465
- "access reaches. Only its owner (or an administrator) can give somebody "
466
- "Can edit, so nothing was saved. Set " + named
467
- + " to Can view and save again.")
468
- if kind == "field":
469
- # A field grant is a visibility and edit wall. Promote a private custom
470
- # field exactly once, then keep the requested Share field role as the
471
- # authoritative override for the legacy permissions bag.
472
- from core import field_permissions, shared_overlay
473
- table_key, field_key = shares.split_field_oid(oid)
474
- defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition(
475
- session, table_key, field_key)
476
- if not isinstance(defn, dict):
477
- raise err(404, "no_object", "no such field, or it is not shared with this account")
478
- if not already_shared:
479
- defn = field_permissions.promote_field(
480
- workspace_key, shared_key, grant_topic,
481
- session.uname, defn, st=session.runtime)
482
- stamped = dict(defn)
483
- stamped["shared"] = True
484
- stamped["granted"] = True
485
- shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
486
- oid = shares.field_oid(grant_topic, field_key)
487
- # ⭐⭐ R4 / W40-T02 (D3) β€” OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER
488
- # THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or
489
- # session.uname` already happened not to transfer ownership β€” incidentally, as a property of
490
- # the callee. R4 names ownership transfer as one of the two halves of the old protection that
491
- # SURVIVES the widening, and a rule that survives by accident is one the next edit deletes
492
- # without noticing. So the branch is explicit: a claimant becomes the owner, and everybody
493
- # else β€” an owner re-saving, an admin, and now an `edit` grantee re-sharing β€” passes the
494
- # EXISTING owner straight back through. `claiming` is the same flag the admission set, so
495
- # there is no second answer to "is this person taking ownership".
496
- out = shares.set_grants(kind, oid, entries,
497
- owner=session.uname if claiming else rec["owner"],
498
- st=session.runtime)
499
- _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
500
- return out
501
-
502
-
503
- def _notify_new_grantees(session, kind, oid, before, after):
504
- """⭐⭐ W32-T28 (owner item 18's last clause, contract C3) β€” tell the RECEIVER, in their Inbox.
505
-
506
- Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
507
- silent: the grant landed in a rail section the receiver had to notice on their own, which is
508
- why "I shared it with you" and "I never saw it" were both true.
509
-
510
- β›” WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
511
- because an alert is a live question about rows; a share is an EVENT that happened once, and
512
- polling for it would mean re-deriving "was this new?" on every inbox open β€” the diff below
513
- only exists here, at the moment the set changes.
514
-
515
- ⚠ ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
516
- so every save re-sends everyone who was already there. Diffing against `before` is what stops
517
- a rename or a role change from ringing the bell for people whose access did not change.
518
- ⚠ `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
519
- the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
520
- ⚠ IT NEVER RAISES. A notification that fails must not fail the share that triggered it β€” the
521
- grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
522
- """
523
- try:
524
- was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
525
- fresh = [str(e.get("user")) for e in (after or ())
526
- if isinstance(e, dict) and e.get("user") not in was
527
- and e.get("user") != shares.EVERYONE]
528
- if not fresh:
529
- return
530
- import core.alerts as alerts
531
-
532
- label, route, view_id = _object_ref(session, kind, oid)
533
- if not route:
534
- # β›” NO ROUTE, NO NOTIFICATION β€” the receiver would get a row that opens nothing, and
535
- # `notification_view` would have to invent a target. Silence is the honest answer
536
- # here; the rail still shows the grant under "Shared with me".
537
- return
538
- sharer = str(session.user.get("name") or session.uname)
539
- for user in fresh:
540
- # ⚠ THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
541
- # agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
542
- # `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
543
- # from there rather than typed again β€” one vocabulary, one owner.
544
- # ⭐⭐ W33-T28 (`ASK C-14`, answered) β€” `actor` IS THE SENDER, AND IT IS THE ONLY WAY
545
- # THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
546
- # are honestly named by their machine; a SHARE has a real person, and only this call
547
- # site knows who. β›” It is passed as its OWN field rather than recovered from the
548
- # `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
549
- # the first time the sentence is reworded, silently, in the header
550
- # [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
551
- alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
552
- detail=f"{sharer} shared this with you", actor=sharer,
553
- st=session.runtime)
554
- except Exception: # noqa: BLE001
555
- return
556
-
557
-
558
- def _object_ref(session, kind, oid):
559
- """`(label, route, view_id)` β€” what to CALL the shared thing, and where it OPENS.
560
-
561
- β›” THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
562
- WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
563
- `target: {module: "database", id: "view_42"}` β€” an instruction to open a database named
564
- `view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
565
- addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
566
- travel. Caught by looking at the notification the driver actually produced, not by reading
567
- the code back.
568
-
569
- ⚠ `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
570
- receiver nothing they can act on, and the id is already in the target.
571
- ⚠ An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
572
- a row that opens nowhere.
573
- """
574
- try:
575
- if kind == "field":
576
- # ⭐⭐ W38-T16 β€” A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
577
- # is a column INSIDE a database, so the target that travels is the DATABASE. Without
578
- # this branch the function falls through to the view/folder loop, finds nothing,
579
- # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
580
- # the receiver is never told, which is the silent half of owner item 18 reopened one
581
- # kind over.
582
- from routes_alerts import route_for_topic
583
- table_key, field_key = shares.split_field_oid(oid)
584
- if not table_key:
585
- return ("A column", None, "")
586
- try:
587
- defn, _shared, _workspace, shared_key, _grant_topic = _field_definition(
588
- session, table_key, field_key)
589
- except Exception: # noqa: BLE001
590
- defn = None
591
- label = str((defn or {}).get("label") or "").strip() or field_key
592
- # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
593
- # by whatever the calling door already held: a `ut_*` database uses its bare key,
594
- # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
595
- # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
596
- # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
597
- # route table being written here, which is how the two come apart.
598
- _WS = "_table_workspace"
599
- scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
600
- if scope is None:
601
- scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
602
- return (label, route_for_topic(scope) or None, "")
603
- if kind == "database":
604
- import core.user_tables as ut
605
- defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
606
- # A user table IS its own route key in both vocabularies (`route_for_topic`).
607
- return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
608
- import core.table_store as table_store
609
- from routes_alerts import route_for_topic
610
- for topic in _topics(session):
611
- ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
612
- hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
613
- if not hit:
614
- continue
615
- route = route_for_topic(topic)
616
- if not route:
617
- break
618
- row = hit[1] if len(hit) > 1 else {}
619
- name = str((row or {}).get("name") or "").strip()
620
- # ⚠ Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
621
- # the grid and stops there rather than naming a view the receiver did not get.
622
- return (name or ("A view" if kind == "view" else "A folder"),
623
- route, str(oid) if kind == "view" else "")
624
- except Exception: # noqa: BLE001
625
- pass
626
- return ({"view": "A view", "folder": "A folder",
627
- "field": "A column"}.get(kind, "An item"), None, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_shares.py β€” the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
2
+
3
+ GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
4
+ PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
5
+ GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
6
+
7
+ `kind` ∈ view | folder | database | field. Roles are `view` | `edit` β€” the same two words the
8
+ view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
9
+ vocabulary in the UI (R10: "the same picker views use").
10
+
11
+ ⭐⭐ **W38-T16 β€” `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
12
+ (`shares.field_oid`). A bare column key repeats across databases β€” `notes` exists on a dozen β€”
13
+ so a grant stored under one would admit the grantee to every `notes` column in the tenant at
14
+ once. β›” THREE functions in this file branch on kind and ALL THREE need the new one, which is not
15
+ obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
16
+ is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
17
+ `_notify_new_grantees` returns early, and the grantee is **granted and never told** β€” owner item
18
+ 18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
19
+ anything that is not a view.
20
+
21
+ ⭐⭐ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β€” OWNER RULING R4, BUILT AS W40-T02** (instruction
22
+ 4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
23
+ owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
24
+ and not in the client:
25
+
26
+ 1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
27
+ `database` and `field` still require the owner or an admin, and the paragraph below is why
28
+ the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
29
+ whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
30
+ 2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
31
+ `edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
32
+ ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
33
+ it) β€” never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
34
+ therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
35
+ `edit` stays the owner's alone.
36
+ 3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
37
+ straight through (`put_share` below, stated rather than incidental).
38
+
39
+ ⭐⭐ **AND R4 BOUNDED ONLY THE ROLE. TWO MORE BOUNDS ARRIVED WITH THE OWNER'S RULING OF
40
+ 2026-08-24, BECAUSE THE GAPS WERE MEASURED ON THE SHIPPED BUILD:**
41
+
42
+ 4. **A RE-SHARER MAY SHARE ONLY WITH SPECIFIC PEOPLE (D-473).** Owner: *"a re-sharer can only
43
+ share to specific people"*. Before this, `[fisch:edit, *:view]` from an `edit` grantee was a
44
+ `200` β€” the role ceiling could not see it, because `*` at `view` never exceeds a `view`
45
+ ceiling. `_audience_added` refuses an `EVERYONE` entry this caller is ADDING, and lets one
46
+ the OWNER already placed ride along untouched, for the same delta reason as (2).
47
+ 5. **A RE-SHARER MAY UN-SHARE ONLY THE PEOPLE THEY SHARED TO (D-474).** Owner: *"a re-sharer
48
+ can only unshare the people it shared to"*. Before this, an `edit` grantee PUTting a list
49
+ that omitted another grantee got `200` and that person's access was gone; `PUT []` left the
50
+ view shared with nobody. Answering this needs a fact the registry did not store, so each
51
+ entry now carries `by` β€” who placed it β€” stamped by `shares.set_grants(granter=…)` and
52
+ consulted by `_unremovable`. β›” An entry with NO `by` (every grant predating the change) is
53
+ the owner's or an admin's to revoke and nobody else's: fail-closed, because from the door
54
+ "unstamped" and "somebody else's" are the same observation.
55
+
56
+ β›” **SO BOTH FEARS THE OLD SENTENCE NAMED ARE NOW ANSWERED, AND THE PARAGRAPH THAT PRICED THE
57
+ SECOND ONE IS GONE RATHER THAN SOFTENED.** It read *"'Widen a users-scoped view to everyone' is
58
+ NOT closed β€” an `edit` grantee may add `*` β€” it is CAPPED at Can view"*, and that was an accurate
59
+ account of R4 that the owner overruled the moment it was put in front of them. *"Grant themselves
60
+ ownership and lock you out"* stays closed by (3) and `set_grants`' sticky owner. A `view` grantee
61
+ still cannot share at all, and still gets `403`. The client greys the editor for
62
+ non-administrators and offers "Can edit" to anyone it greys in; that is a courtesy, and these
63
+ checks are the wall.
64
+
65
+ ⚠ **WHAT `GET` NOW DISCLOSES.** The grant record carries `by`, and this route returns the record
66
+ verbatim β€” so anybody who may read a view's grant list also learns who added each person, a
67
+ `view` grantee included. Accepted deliberately (the alternative is a second, stripped read path
68
+ and therefore a second answer to one question), and stated here because this file states costs
69
+ rather than leaving them to be discovered.
70
+
71
+ ⚠ **THE GRANT NEVER WIDENS PAST THE MODULE WALL β€” ON A GOVERNED MODULE.** `*` ("everyone") means
72
+ every account that can already open the surface: `require_session` plus the topic's own gate run
73
+ first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
74
+ closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
75
+ could already reach the data ([[aios-permissioning]]).
76
+
77
+ β›”β›” **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
78
+ MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
79
+ and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
80
+ DECLARED for a `ut_*` database** β€” `routes_tables.py` makes zero `perm_scope` calls and passes
81
+ `hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
82
+ by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β€” every row, every
83
+ column β€” and an `*` database grant admits every account in the tenant to all of it.
84
+ That is a real capability, deliberately kept; what was wrong was a docstring promising a second
85
+ wall that does not exist for this kind. Scoping user tables is booked, not done
86
+ (`waves/wave32/sharing-audit.md` S-8).
87
+
88
+ ⚠ **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
89
+ registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
90
+ β€” the view's own `permissions` β€” is what actually decides who may OPEN a view.** A grant here
91
+ whose object is invisible under that one is a row in a list that opens a refusal, which is what
92
+ made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
93
+ the two vocabularies are still two.
94
+ """
95
+ from fastapi import APIRouter, Body, Depends
96
+
97
+ import core.shares as shares
98
+ import core.users as users
99
+ from deps import Session, err, require_session
100
+ # ⭐ W32-T28 (C3) β€” the SHARE notification's topic word, imported from the module that CLASSIFIES
101
+ # it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
102
+ # reader agreeing about one string is the whole difference between an Inbox row that opens the
103
+ # shared database and one that is quietly unclickable.
104
+ from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
105
+
106
+ router = APIRouter(prefix="/api/v1")
107
+
108
+
109
+ def _kind_or_400(raw):
110
+ try:
111
+ return shares._check_kind(raw)
112
+ except ValueError as e:
113
+ raise err(400, "bad_kind", str(e))
114
+
115
+
116
+ # ── ⭐⭐ WAVE 32 Β· T26 (owner item 18, ruling R12) β€” THE WALL THIS FILE SAID IT HAD ─────────────
117
+ #
118
+ # `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
119
+ # means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
120
+ # strings off the URL and the only dependency was `require_session`, so any signed-in account
121
+ # could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
122
+ # an object with no grant record skipped the check entirely and the caller was stamped OWNER β€”
123
+ # sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
124
+ # argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
125
+ #
126
+ # ⚠ AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
127
+ # "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
128
+ # victim next opens the dialog.
129
+
130
+ #: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
131
+ #: route is not told which topic β€” so resolving one means asking each.
132
+ _BUILTIN_TOPICS = ("customer", "product")
133
+
134
+
135
+ def _field_storage_keys(table_key):
136
+ """Resolve the client-facing field topic to its durable stores and grant topic."""
137
+ raw = str(table_key or "").strip()
138
+ if raw in ("customer_data", "customer_table_workspace"):
139
+ return "customer_table_workspace", "customer_table_workspace", "customer_data"
140
+ if raw in ("product_data", "product_table_workspace"):
141
+ return "product_table_workspace", "product_table_workspace", "product_data"
142
+ if raw.startswith("ut_"):
143
+ bare = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
144
+ return f"{bare}_table_workspace", bare, bare
145
+ workspace = raw if raw.endswith("_table_workspace") else f"{raw}_table_workspace"
146
+ shared = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
147
+ return workspace, shared, shared
148
+
149
+
150
+ def _field_definition(session, table_key, field_key):
151
+ """Return the shared/private definition and the keys used by its write paths."""
152
+ workspace_key, shared_key, grant_topic = _field_storage_keys(table_key)
153
+ try:
154
+ from core import shared_overlay
155
+ shared = (shared_overlay.fields(shared_key, st=session.runtime) or {}).get(field_key)
156
+ if isinstance(shared, dict):
157
+ return shared, True, workspace_key, shared_key, grant_topic
158
+ import core.table_store as table_store
159
+ private = (table_store.make(workspace_key, st=session.runtime)
160
+ .workspace(session.uname).get("fields") or {}).get(field_key)
161
+ if isinstance(private, dict):
162
+ return private, False, workspace_key, shared_key, grant_topic
163
+ except Exception: # noqa: BLE001
164
+ pass
165
+ return None, False, workspace_key, shared_key, grant_topic
166
+
167
+
168
+ def _field_owner(session, definition, already_shared):
169
+ """Resolve the creator for the share claim wall.
170
+
171
+ A private field is already namespaced by the caller's own workspace. Older field records
172
+ from before the host-side creator stamp therefore remain safely claimable by that workspace
173
+ owner, while a shared definition with no creator stays admin-only because its storage is
174
+ tenant-wide and cannot identify an owner from residency alone.
175
+ """
176
+ owner = str((definition or {}).get("createdBy") or "").strip()
177
+ if owner:
178
+ return owner
179
+ return str(session.uname or "").strip() if not already_shared else ""
180
+
181
+
182
+ def _topics(session):
183
+ """Every topic whose workspace could hold a view or folder for this tenant.
184
+
185
+ ⚠ `all_defs`, never `all_tables` β€” the latter is the whole 28.6 MB row payload (~703 ms on
186
+ tenant #0) to answer a question about KEYS (D-185).
187
+ """
188
+ try:
189
+ import core.user_tables as ut
190
+ return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
191
+ except Exception: # noqa: BLE001
192
+ return _BUILTIN_TOPICS
193
+
194
+
195
+ def _owns_object(session, kind, oid):
196
+ """May this caller CLAIM an object that has no grant record yet β€” i.e. do they own it?
197
+
198
+ β›” THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
199
+ each topic's workspace in turn, which is N store reads; making every share call pay that
200
+ would put a loop on a route the manage-access dialog opens. The dangerous path is the one
201
+ where a caller is about to be stamped OWNER of something nobody owns β€” so the resolution runs
202
+ exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
203
+ """
204
+ if session.admin:
205
+ return True
206
+ if kind == "field":
207
+ # ⭐⭐ W38-T16 β€” A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
208
+ # (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
209
+ # D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
210
+ # deletable by one person and shareable by another.
211
+ # ⚠ THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
212
+ # a brand-new column has no grant record, so `put_share` falls to this predicate β€” and
213
+ # without it the column's own creator is answered `404 no_object` on the first attempt to
214
+ # share the thing they just made.
215
+ table_key, field_key = shares.split_field_oid(oid)
216
+ if not table_key:
217
+ return False
218
+ defn, _shared, _workspace, _shared_key, _grant_topic = _field_definition(
219
+ session, table_key, field_key)
220
+ owner = _field_owner(session, defn, _shared)
221
+ return bool(defn) and owner.lower() == str(session.uname).strip().lower()
222
+ if kind == "database":
223
+ # ⚠ `may_open` is THE resolver for a user table (its own docstring says so) and already
224
+ # admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
225
+ # here would be the second definition this wave keeps finding.
226
+ try:
227
+ import core.user_tables as ut
228
+ return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
229
+ st=session.runtime))
230
+ except Exception: # noqa: BLE001
231
+ return False
232
+ try:
233
+ import core.table_store as table_store
234
+ except Exception: # noqa: BLE001
235
+ return False
236
+ for topic in _topics(session):
237
+ try:
238
+ ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
239
+ hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
240
+ except Exception: # noqa: BLE001
241
+ continue
242
+ if hit:
243
+ # `find_view`/`find_folder` answer `(owner_username, …)`. The claim belongs to the
244
+ # person whose personal stratum holds it β€” anybody else reaching this line is
245
+ # exactly the case S-1 describes.
246
+ return str(hit[0]) == str(session.uname)
247
+ return False
248
+
249
+
250
+ def _can_see_object(session, kind, oid):
251
+ """May this caller READ an object's grant list β€” i.e. can they reach the object at all?
252
+
253
+ β›”β›” THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
254
+ REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
255
+ ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
256
+ PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
257
+ `permissions.edit = "collaborative"` and no grant record yet β€” a view bob **can open and edit
258
+ in the grid** β€” answered `404` when bob opened its manage-access dialog. Measured before
259
+ fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
260
+ `404 no_object`.
261
+ ⚠ THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
262
+ and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
263
+ `permissions` (system B) β€” which is the one that actually decides who may OPEN it.
264
+ ⚠ And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
265
+ gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β€”
266
+ 'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
267
+ legitimate collaborator their view does not exist.
268
+
269
+ β›” THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
270
+ owner β€” that is S-1, and widening this predicate onto `put_share` would re-open it.
271
+ """
272
+ if _owns_object(session, kind, oid):
273
+ return True
274
+ if kind != "view":
275
+ # A folder carries no per-object visibility flag of its own, and a database's `may_open`
276
+ # (inside `_owns_object`) already admits grantees. Nothing wider to ask.
277
+ # ⭐ W38-T16 β€” AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
278
+ # `get_share` tests `role is None` first and a grant answers a role, so the only caller
279
+ # left is an account with no relationship to the column at all. Widening it would let any
280
+ # signed-in session enumerate who holds which column on a database they cannot open.
281
+ return False
282
+ try:
283
+ import core.table_store as table_store
284
+ for topic in _topics(session):
285
+ hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
286
+ if hit:
287
+ return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
288
+ session.uname, is_admin=session.admin))
289
+ except Exception: # noqa: BLE001
290
+ return False
291
+ return False
292
+
293
+
294
+ def _entries_or_400(session, entries):
295
+ """Validate a grant list against the tenant's REAL, ACTIVE accounts β€” and refuse BY NAME.
296
+
297
+ β›” `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
298
+ a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
299
+ of a string and the role word β€” never that the user EXISTS, is ACTIVE, or is in this tenant**,
300
+ so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
301
+ sharer believes the person has access. That is item 18's plain reading.
302
+ ⚠ The correct population is computed THREE FUNCTIONS BELOW and served to the picker
303
+ (`_people`). One route, two populations, and the write door was the permissive one.
304
+ ⚠ `*` (everyone) is not a user and is admitted deliberately β€” it is R10's vocabulary for
305
+ "every account that can already open the surface".
306
+ """
307
+ known = {p["username"].strip().lower() for p in _people(session.tenant)}
308
+ unknown = []
309
+ for e in entries or ():
310
+ if not isinstance(e, dict):
311
+ continue
312
+ user = str(e.get("user") or "").strip().lower()
313
+ if user and user != shares.EVERYONE and user not in known:
314
+ unknown.append(user)
315
+ if unknown:
316
+ raise err(400, "unknown_people",
317
+ "no active account in this workspace is named "
318
+ + ", ".join(sorted(set(unknown)))
319
+ + ". Nothing was shared. Pick people from the list rather than typing a name.")
320
+
321
+
322
+ def _named(who):
323
+ """The people in a refusal, in the words the person reading it uses.
324
+
325
+ ONE spelling, used by all three re-share refusals below. `*` is never printed raw: the store's
326
+ wildcard is a single character, and a 403 reading *"set * to Can view"* names nothing a person
327
+ can find in the dialog they are looking at.
328
+ """
329
+ return ", ".join("everyone in this workspace" if w == shares.EVERYONE else w
330
+ for w in sorted(who))
331
+
332
+
333
+ def _audience_added(entries, held):
334
+ """⭐⭐ D-473 β€” the `EVERYONE` grant this caller is ADDING, or the empty set.
335
+
336
+ OWNER RULING 2026-08-24, verbatim: *"a re-sharer can only share to specific people"*. R4's
337
+ ceiling bounded the ROLE a re-sharer may hand out and said nothing about the AUDIENCE, and the
338
+ gap was measured rather than argued: an `edit` grantee PUTting `[fisch:edit, *:view]` was
339
+ answered `200`, widening a two-person view to the whole tenant at `Can view`. The role check
340
+ could not catch it, because `*` at `view` never exceeds a `view` ceiling.
341
+
342
+ β›” IT IS THE ADDITION THAT IS REFUSED, NOT THE PRESENCE, and that is the same shape as the
343
+ role check one arm above, for the same reason: the `PUT` REPLACES, so `ShareDialog.save`
344
+ re-sends the WHOLE list every time. An `*` the OWNER placed rides along in every payload the
345
+ re-sharer is able to produce, and refusing on presence would `403` every save on a view the
346
+ owner had already opened to everyone β€” a re-sharer locked out of a list they may legitimately
347
+ edit, with a message about a row they did not touch.
348
+
349
+ β›” ROLE IS NOT CONSULTED HERE, DELIBERATELY. This answers "may this caller widen the
350
+ AUDIENCE", and `*` held at `view` and resubmitted at `edit` is a ROLE escalation that the
351
+ check above already refuses, by name. Two questions, two predicates
352
+ ([[one-evaluator-per-question]]) β€” and separable is also what lets a gate disarm one of them
353
+ in memory and prove the other still fires.
354
+
355
+ ⚠ `_clean_entries`, NEVER THE RAW BODY: an entry with a junk role is dropped by the writer,
356
+ so reading the raw list would refuse a widening that was never going to be stored.
357
+ """
358
+ if shares.EVERYONE in (held or {}):
359
+ return set()
360
+ return {e["user"] for e in shares._clean_entries(entries) if e["user"] == shares.EVERYONE}
361
+
362
+
363
+ def _unremovable(held_rows, entries, uname):
364
+ """⭐⭐ D-474 β€” the people this caller is dropping from the list but may NOT revoke.
365
+
366
+ OWNER RULING 2026-08-24, verbatim: *"a re-sharer can only unshare the people it shared to"*.
367
+ R4 bounded what a re-sharer may HAND OUT and left what they may TAKE AWAY unbounded, and both
368
+ halves of that were measured: an `edit` grantee PUTting a list that omits another grantee was
369
+ answered `200` and that person's access was gone; `PUT []` left the view shared with nobody.
370
+
371
+ β›”β›” AN ENTRY WITH NO `by` IS NOT REMOVABLE BY A RE-SHARER β€” ONLY BY THE OWNER OR AN ADMIN,
372
+ AND THAT IS THE FAIL-CLOSED DIRECTION RATHER THAN AN OVERSIGHT. Every grant written before
373
+ provenance existed carries no stamp, so "no `by`" and "granted by somebody else" are
374
+ indistinguishable from here. Reading absence as *"nobody claims it, so anyone may take it"*
375
+ would hand every re-sharer the power to revoke the entire pre-existing grant set on day one
376
+ of this change, which is the exact capability the ruling withholds. A re-sharer must not be
377
+ able to revoke a grant they cannot PROVE they made [[aios-permissioning]].
378
+
379
+ ⚠ SO A RE-SHARER CANNOT REMOVE THEMSELVES EITHER, AND THAT IS STATED BECAUSE IT LOOKS LIKE A
380
+ BUG. Their own row was placed by the owner, so it carries the owner's `by` and lands in this
381
+ set. Read literally, the ruling says a re-sharer unshares only who THEY shared to, and their
382
+ own grant is not one of those. Leaving the view is the owner's to do, like every other
383
+ revocation of an owner-placed grant. β›” Do not carve an exception here without a ruling: the
384
+ carve-out is indistinguishable from "a re-sharer may revoke any row whose `by` names the
385
+ owner", which is the wall itself.
386
+
387
+ ⚠ `_clean_entries`, NEVER THE RAW BODY, AND THIS IS THE HOLE THAT SHAPE CLOSES. A role the
388
+ writer rejects is a row that will NOT be stored, so `{user: victim, role: "nonsense"}` looks
389
+ present in the raw payload and is a silent REVOCATION once written. Asking the same
390
+ normaliser the store uses is what makes "submitted" mean the same thing at both ends.
391
+ """
392
+ me = str(uname or "").strip().lower()
393
+ submitted = {e["user"] for e in shares._clean_entries(entries)}
394
+ stuck = set()
395
+ for user, row in (held_rows or {}).items():
396
+ if user in submitted:
397
+ continue
398
+ stamp = str((row or {}).get("by") or "").strip().lower()
399
+ if not me or not stamp or stamp != me:
400
+ stuck.add(user)
401
+ return stuck
402
+
403
+
404
+ @router.get("/share/mine")
405
+ def my_shares(session: Session = Depends(require_session)):
406
+ """Everything shared WITH me, by kind β€” the "Shared with me" rail section (R10).
407
+
408
+ Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
409
+ in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
410
+ that is not malformed at all.
411
+ """
412
+ return shares.shared_with(session.uname, st=session.runtime)
413
+
414
+
415
+ @router.get("/share/{kind}/{oid}")
416
+ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
417
+ kind = _kind_or_400(kind)
418
+ if kind == "field":
419
+ table_key, field_key = shares.split_field_oid(oid)
420
+ if table_key and field_key:
421
+ oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
422
+ rec = shares.grants(kind, oid, st=session.runtime)
423
+ role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
424
+ may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
425
+ st=session.runtime)
426
+ # W39-T29 β€” the dialog opens with GET before its first PUT. Until that PUT exists the grant
427
+ # registry has no owner to return from `may_administer`, even though the same caller may safely
428
+ # claim their own object through the PUT path below. Reflect that exact claim predicate here:
429
+ # a Member who owns an unshared View receives the people picker; a collaborator still does not.
430
+ if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
431
+ may_admin = True
432
+ # ⭐ W32-T26 (audit S-3) β€” A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
433
+ # who owns it, everyone it is granted to, and the tenant's whole username↔name directory β€”
434
+ # to any signed-in session, about objects it cannot open. Now a caller with no role on an
435
+ # object must prove they can reach it, and gets a 404 otherwise: the same answer a
436
+ # non-existent id gives, so the route cannot be used to probe which ids are real.
437
+ # ⚠ `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
438
+ # caller who has no relationship with the object at all.
439
+ if role is None and not _can_see_object(session, kind, oid):
440
+ raise err(404, "no_object", "no such item, or it is not shared with this account")
441
+ return {
442
+ **rec,
443
+ "role": role,
444
+ "mayAdminister": may_admin,
445
+ # ⚠ WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
446
+ # them. `assignable_people` serves bare display names because `user`-kind CELLS store
447
+ # display names β€” that list's shape cannot change without migrating cell values β€” so
448
+ # this route serves objects of its own. Existing grants that were written as lowercased
449
+ # display names are normalised by the wave-21 cleanup script.
450
+ # ⭐ W32-T26 (audit S-3) β€” the roster is the EDITOR's data, so it rides only for a caller
451
+ # who may open the editor. A read-only grantee gets the grant list (their fair question is
452
+ # "who else has this?") and not a directory of every account in the workspace.
453
+ # ⭐⭐ W40-T02 / R4 β€” AND THAT RULE IS WHY THIS LINE NEEDED NO EDIT. `may_administer` now
454
+ # answers True for an `edit` grantee on a VIEW, which MOVES that account into "may open
455
+ # the editor" β€” so the picker they need arrives by the roster riding on the same flag it
456
+ # always did. Gating it on anything else (owner, `role == 'owner'`, a fresh predicate)
457
+ # would be a second answer to a question this file already answers once, and would leave
458
+ # the new grantee with an editor and no people to put in it. A `view` grantee is still
459
+ # `may_admin=False` here and still gets `[]`.
460
+ "people": _people(session.tenant) if may_admin else [],
461
+ }
462
+
463
+
464
+ def _people(tenant):
465
+ """[{username, name}] for this tenant β€” same population as `assignable_people`, with the
466
+ BINDING identity alongside the display one."""
467
+ try:
468
+ reg = users.registry() or {}
469
+ except Exception:
470
+ return []
471
+ want = str(tenant or '').strip().lower()
472
+ out = []
473
+ for uname, u in reg.items():
474
+ if not isinstance(u, dict) or u.get('active') is False:
475
+ continue
476
+ if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
477
+ continue
478
+ out.append({"username": str(uname), "name": str(u.get('name') or uname)})
479
+ return sorted(out, key=lambda p: p["name"].lower())
480
+
481
+
482
+ @router.put("/share/{kind}/{oid}")
483
+ def put_share(kind: str, oid: str, body: dict = Body(default=None),
484
+ session: Session = Depends(require_session)):
485
+ kind = _kind_or_400(kind)
486
+ if kind == "field":
487
+ table_key, field_key = shares.split_field_oid(oid)
488
+ if table_key and field_key:
489
+ oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
490
+ body = body or {}
491
+ rec = shares.grants(kind, oid, st=session.runtime)
492
+ # `claiming` is the "no owner yet, and this caller may become one" branch, hoisted to a name
493
+ # because TWO decisions below need it: the ceiling (a claimant is about to be the owner, so
494
+ # their ceiling is an owner's) and the owner written back (D3 β€” see `set_grants` at the end).
495
+ claiming = False
496
+ # An object with NO grant record yet has no owner β€” the first person to share it claims it.
497
+ # That is safe because reaching this route at all means passing the surface's own wall, and
498
+ # the alternative (refusing until somebody seeds an owner) would make a brand-new folder
499
+ # unshareable by the person who just made it.
500
+ if rec["owner"]:
501
+ if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
502
+ st=session.runtime):
503
+ # ⭐ R4 / W40-T02 β€” `may_administer` now also admits an `edit` grantee on a VIEW, so
504
+ # the population refused here is narrower than the code word `not_owner` suggests: a
505
+ # `view` grantee, or an account with an `edit` role on a kind outside
506
+ # `shares.RESHARE_KINDS`. The code string is kept because clients match on it.
507
+ raise err(403, "not_owner",
508
+ "only the owner of this item (or an administrator) can change who it is "
509
+ "shared with")
510
+ # β›”β›” W32-T26 (audit S-1) β€” THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
511
+ # is still claimed by the first person to share it β€” that rule is right, and refusing until
512
+ # somebody seeds an owner would make a brand-new folder unshareable by the person who just
513
+ # made it. What was missing is the half the old comment ASSERTED and the code never did: the
514
+ # claimant has to be able to reach the object. Without this, any signed-in account could
515
+ # stamp itself owner of an id it had never seen and lock the real creator out for good.
516
+ elif not _owns_object(session, kind, oid):
517
+ raise err(404, "no_object", "no such item, or it is not shared with this account")
518
+ else:
519
+ claiming = True
520
+ entries = body.get("entries")
521
+ if not isinstance(entries, list):
522
+ raise err(400, "bad_entries",
523
+ "entries must be a list of {user, role}. Send [] to un-share, which is how "
524
+ "revoking is expressed")
525
+ _entries_or_400(session, entries)
526
+ # ⭐⭐ R4 / W40-T02 β€” THE CEILING. `may_administer` above now opens this door to an `edit`
527
+ # grantee on a VIEW, so R4's other half ("a re-share may never exceed the role the re-sharer
528
+ # holds") needs a check of its own: that caller may hand out `view`, and conferring `edit`
529
+ # stays the owner's or an administrator's.
530
+ #
531
+ # β›” AFTER THE ADMISSION, NEVER BEFORE, AND THAT ORDER IS A SECURITY PROPERTY. A caller with
532
+ # no role at all must keep receiving `404 no_object` (audit S-1/S-3: a stranger learns
533
+ # nothing, so this route cannot be used to probe which ids are real). A ceiling raised first
534
+ # would answer that caller `403` and turn the one route hardened against id-probing back into
535
+ # an oracle that confirms an id exists. It also runs before the `field` promotion below, so a
536
+ # refusal cannot leave a column promoted with no grant written.
537
+ #
538
+ # β›” AND IT IS THE DELTA, NOT EVERY ROW OF THE BODY β€” read off the shipped client, not
539
+ # assumed. `ShareDialog.save` PUTs the WHOLE list every time ("a body assembled from a delta
540
+ # would revoke everyone it failed to mention"), so the re-sharer's OWN `{user, role: "edit"}`
541
+ # row rides in every payload they are able to produce. Refusing per-entry would `403` the
542
+ # exact re-share this ticket exists to enable, and the only body that would pass is one that
543
+ # revokes the re-sharer. So what is refused is edit access this caller is CREATING: a name
544
+ # arriving at `edit`, or an existing `view` grantee raised to it. A row that already stood at
545
+ # `edit` was the OWNER's decision, and is not this caller's to be refused for.
546
+ #
547
+ # ⚠ A CLAIMANT IS AN OWNER. The branch above admits a Member who owns an object that has no
548
+ # grant record yet, and `set_grants` is about to stamp them owner β€” asking the registry for
549
+ # their role here would answer `None` (no record exists to hold one) and refuse the first
550
+ # `edit` grant on every newly created view. Same predicate as the door, one line apart.
551
+ ceiling = "edit" if claiming else shares.max_grantable_role(
552
+ kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
553
+ #
554
+ # ⭐⭐ OWNER RULING 2026-08-24 (D-473 + D-474) β€” AND THE CEILING IS NOW ONE OF THREE WALLS IN
555
+ # THIS BLOCK, NOT THE WALL. R4 bounded the ROLE a re-sharer may hand out and was silent on the
556
+ # other two questions a re-share asks, so both gaps shipped and both were measured on the
557
+ # build: an `edit` grantee could PUT `[fisch:edit, *:view]` and widen a two-person view to the
558
+ # whole tenant (200), and could PUT a list omitting another grantee β€” or `[]` β€” and revoke
559
+ # people they never granted (200). The owner's answer settles both in one sentence: *"no a
560
+ # re-sharer can only share to specific people and a re-sharer can only unshare the people it
561
+ # shared to"*.
562
+ #
563
+ # β›” THREE PREDICATES, THREE FUNCTIONS, ONE ORDER: role, then audience, then revocation. They
564
+ # are separate because they answer separate questions and because a wall that cannot be
565
+ # disarmed ALONE cannot be proven alone β€” `verify_scopes.section_reshare_bounds` patches each
566
+ # one in memory and requires exactly its own leg to go red, which a single fused `if` would
567
+ # make impossible ([[a-declared-gate-is-an-unchecked-claim]]).
568
+ # ⚠ ROLE AND AUDIENCE COMPOSE, AND THE ORDER DECIDES WHICH REFUSAL A PERSON READS. `*`
569
+ # submitted at `edit` while held at `view` is BOTH an escalation and (if unheld) a widening;
570
+ # the role check runs first and names the fix that is actually available to this caller
571
+ # ("set it to Can view"), which is the more useful of the two sentences.
572
+ if ceiling != "edit":
573
+ # The prior ROWS, not just their roles: the revocation wall needs each entry's `by`, and
574
+ # reading it from a second place would be a second answer to "what does the store hold".
575
+ held_rows = {e.get("user"): e for e in (rec["entries"] or ())
576
+ if isinstance(e, dict) and e.get("user")}
577
+ held = {u: e.get("role") for u, e in held_rows.items()}
578
+ noun = {"view": "view", "folder": "folder",
579
+ "database": "database", "field": "column"}.get(kind, "item")
580
+ raised = set()
581
+ for e in entries:
582
+ if not isinstance(e, dict):
583
+ continue
584
+ who = str(e.get("user") or "").strip().lower()
585
+ if who and str(e.get("role") or "").strip().lower() == "edit" \
586
+ and held.get(who) != "edit":
587
+ raised.add(who)
588
+ if raised:
589
+ raise err(403, "grant_exceeds_role",
590
+ "you can share this " + noun + " at Can view, which is as far as your own "
591
+ "access reaches. Only its owner (or an administrator) can give somebody "
592
+ "Can edit, so nothing was saved. Set " + _named(raised)
593
+ + " to Can view and save again.")
594
+ # ⭐⭐ D-473 β€” THE AUDIENCE. A re-sharer names PEOPLE; reaching "everyone" is the owner's.
595
+ if _audience_added(entries, held):
596
+ raise err(403, "grant_exceeds_audience",
597
+ "you can share this " + noun + " with specific people, which is as far as "
598
+ "your own access reaches. Only its owner (or an administrator) can open it "
599
+ "to everyone in this workspace, so nothing was saved. Remove Everyone from "
600
+ "the list, add the people you meant by name, and save again.")
601
+ # ⭐⭐ D-474 β€” THE REVOCATION. An omission IS a revocation on a replacing PUT, so this is
602
+ # the only place a removal can be refused. β›” REFUSED WHOLE: `set_grants` has not run, so
603
+ # a payload carrying a legitimate addition ALONGSIDE a forbidden removal saves neither.
604
+ # That is deliberate and it is what the message promises ("nothing was saved") β€” a
605
+ # half-applied permission change is worse than a refused one, because the person reading
606
+ # the toast has no way to tell which half took.
607
+ stuck = _unremovable(held_rows, entries, session.uname)
608
+ if stuck:
609
+ raise err(403, "revoke_not_yours",
610
+ "you can remove the people you shared this " + noun + " with, and this "
611
+ "workspace has no record of you sharing it with " + _named(stuck)
612
+ + ". Only its owner (or an administrator) can remove them, so nothing was "
613
+ "saved. Put them back on the list and save again.")
614
+ if kind == "field":
615
+ # A field grant is a visibility and edit wall. Promote a private custom
616
+ # field exactly once, then keep the requested Share field role as the
617
+ # authoritative override for the legacy permissions bag.
618
+ from core import field_permissions, shared_overlay
619
+ table_key, field_key = shares.split_field_oid(oid)
620
+ defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition(
621
+ session, table_key, field_key)
622
+ if not isinstance(defn, dict):
623
+ raise err(404, "no_object", "no such field, or it is not shared with this account")
624
+ if not already_shared:
625
+ defn = field_permissions.promote_field(
626
+ workspace_key, shared_key, grant_topic,
627
+ session.uname, defn, st=session.runtime)
628
+ stamped = dict(defn)
629
+ stamped["shared"] = True
630
+ stamped["granted"] = True
631
+ shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
632
+ oid = shares.field_oid(grant_topic, field_key)
633
+ # ⭐⭐ R4 / W40-T02 (D3) β€” OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER
634
+ # THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or
635
+ # session.uname` already happened not to transfer ownership β€” incidentally, as a property of
636
+ # the callee. R4 names ownership transfer as one of the two halves of the old protection that
637
+ # SURVIVES the widening, and a rule that survives by accident is one the next edit deletes
638
+ # without noticing. So the branch is explicit: a claimant becomes the owner, and everybody
639
+ # else β€” an owner re-saving, an admin, and now an `edit` grantee re-sharing β€” passes the
640
+ # EXISTING owner straight back through. `claiming` is the same flag the admission set, so
641
+ # there is no second answer to "is this person taking ownership".
642
+ # ⭐⭐ D-474 β€” `granter` IS THIS SESSION, ON EVERY SAVE INCLUDING THE OWNER'S. Provenance is
643
+ # recorded for whoever adds a person, not only for a re-sharer: an owner-placed grant carrying
644
+ # NO stamp is indistinguishable from a pre-provenance one, and `_unremovable` would then be
645
+ # deciding on the store's AGE rather than on who granted what. `set_grants` stamps only
646
+ # entries that are NEW to the record and never re-stamps an existing one, so an owner
647
+ # re-saving a list does not quietly take provenance off the re-sharer who built it.
648
+ # β›”β›” THE THREE REFUSALS ABOVE WERE DECIDED AGAINST `rec`, WHICH WAS READ AT THE TOP OF
649
+ # THIS FUNCTION. Handing `expect` to the writer is what makes them true at the moment of the
650
+ # write rather than at the moment of the read: a concurrent save lands between the two, and
651
+ # a wave-40 adversarial probe drove a re-sharer's PUT being ACCEPTED while the grant the
652
+ # owner had just added disappeared. See `shares.set_grants`' own note.
653
+ try:
654
+ out = shares.set_grants(kind, oid, entries,
655
+ owner=session.uname if claiming else rec["owner"],
656
+ granter=session.uname,
657
+ st=session.runtime,
658
+ expect=rec["entries"])
659
+ except shares.GrantsChanged as exc:
660
+ raise err(409, "grants_changed", str(exc))
661
+ _notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
662
+ return out
663
+
664
+
665
+ def _notify_new_grantees(session, kind, oid, before, after):
666
+ """⭐⭐ W32-T28 (owner item 18's last clause, contract C3) β€” tell the RECEIVER, in their Inbox.
667
+
668
+ Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
669
+ silent: the grant landed in a rail section the receiver had to notice on their own, which is
670
+ why "I shared it with you" and "I never saw it" were both true.
671
+
672
+ β›” WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
673
+ because an alert is a live question about rows; a share is an EVENT that happened once, and
674
+ polling for it would mean re-deriving "was this new?" on every inbox open β€” the diff below
675
+ only exists here, at the moment the set changes.
676
+
677
+ ⚠ ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
678
+ so every save re-sends everyone who was already there. Diffing against `before` is what stops
679
+ a rename or a role change from ringing the bell for people whose access did not change.
680
+ ⚠ `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
681
+ the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
682
+ ⚠ IT NEVER RAISES. A notification that fails must not fail the share that triggered it β€” the
683
+ grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
684
+ """
685
+ try:
686
+ was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
687
+ fresh = [str(e.get("user")) for e in (after or ())
688
+ if isinstance(e, dict) and e.get("user") not in was
689
+ and e.get("user") != shares.EVERYONE]
690
+ if not fresh:
691
+ return
692
+ import core.alerts as alerts
693
+
694
+ label, route, view_id = _object_ref(session, kind, oid)
695
+ if not route:
696
+ # β›” NO ROUTE, NO NOTIFICATION β€” the receiver would get a row that opens nothing, and
697
+ # `notification_view` would have to invent a target. Silence is the honest answer
698
+ # here; the rail still shows the grant under "Shared with me".
699
+ return
700
+ sharer = str(session.user.get("name") or session.uname)
701
+ for user in fresh:
702
+ # ⚠ THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
703
+ # agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
704
+ # `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
705
+ # from there rather than typed again β€” one vocabulary, one owner.
706
+ # ⭐⭐ W33-T28 (`ASK C-14`, answered) β€” `actor` IS THE SENDER, AND IT IS THE ONLY WAY
707
+ # THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
708
+ # are honestly named by their machine; a SHARE has a real person, and only this call
709
+ # site knows who. β›” It is passed as its OWN field rather than recovered from the
710
+ # `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
711
+ # the first time the sentence is reworded, silently, in the header
712
+ # [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
713
+ alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
714
+ detail=f"{sharer} shared this with you", actor=sharer,
715
+ st=session.runtime)
716
+ except Exception: # noqa: BLE001
717
+ return
718
+
719
+
720
+ def _object_ref(session, kind, oid):
721
+ """`(label, route, view_id)` β€” what to CALL the shared thing, and where it OPENS.
722
+
723
+ β›” THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
724
+ WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
725
+ `target: {module: "database", id: "view_42"}` β€” an instruction to open a database named
726
+ `view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
727
+ addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
728
+ travel. Caught by looking at the notification the driver actually produced, not by reading
729
+ the code back.
730
+
731
+ ⚠ `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
732
+ receiver nothing they can act on, and the id is already in the target.
733
+ ⚠ An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
734
+ a row that opens nowhere.
735
+ """
736
+ try:
737
+ if kind == "field":
738
+ # ⭐⭐ W38-T16 β€” A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
739
+ # is a column INSIDE a database, so the target that travels is the DATABASE. Without
740
+ # this branch the function falls through to the view/folder loop, finds nothing,
741
+ # answers `route=None` β€” and `_notify_new_grantees` returns EARLY. The grant lands and
742
+ # the receiver is never told, which is the silent half of owner item 18 reopened one
743
+ # kind over.
744
+ from routes_alerts import route_for_topic
745
+ table_key, field_key = shares.split_field_oid(oid)
746
+ if not table_key:
747
+ return ("A column", None, "")
748
+ try:
749
+ defn, _shared, _workspace, shared_key, _grant_topic = _field_definition(
750
+ session, table_key, field_key)
751
+ except Exception: # noqa: BLE001
752
+ defn = None
753
+ label = str((defn or {}).get("label") or "").strip() or field_key
754
+ # ⚠ TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
755
+ # by whatever the calling door already held: a `ut_*` database uses its bare key,
756
+ # while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
757
+ # `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
758
+ # `customer_data`), so the suffix comes off before it is asked β€” rather than a second
759
+ # route table being written here, which is how the two come apart.
760
+ _WS = "_table_workspace"
761
+ scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
762
+ if scope is None:
763
+ scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
764
+ return (label, route_for_topic(scope) or None, "")
765
+ if kind == "database":
766
+ import core.user_tables as ut
767
+ defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
768
+ # A user table IS its own route key in both vocabularies (`route_for_topic`).
769
+ return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
770
+ import core.table_store as table_store
771
+ from routes_alerts import route_for_topic
772
+ for topic in _topics(session):
773
+ ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
774
+ hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
775
+ if not hit:
776
+ continue
777
+ route = route_for_topic(topic)
778
+ if not route:
779
+ break
780
+ row = hit[1] if len(hit) > 1 else {}
781
+ name = str((row or {}).get("name") or "").strip()
782
+ # ⚠ Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
783
+ # the grid and stops there rather than naming a view the receiver did not get.
784
+ return (name or ("A view" if kind == "view" else "A folder"),
785
+ route, str(oid) if kind == "view" else "")
786
+ except Exception: # noqa: BLE001
787
+ pass
788
+ return ({"view": "A view", "folder": "A folder",
789
+ "field": "A column"}.get(kind, "An item"), None, "")
platform/core/shares.py CHANGED
@@ -1,414 +1,560 @@
1
- """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
-
3
- WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
- already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
- that can add or revoke people later, on any of the three.
6
-
7
- WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
- (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
- But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
- definition; giving each its own grant field would put the same three-line permission decision in
11
- three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
- three callers.
13
-
14
- shares.set_grants(kind, oid, entries, owner=…, st=…) # replaces the whole grant set
15
- shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
16
- shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
17
- shares.max_grantable_role(kind, oid, user, …) # -> 'edit'|'view'|None (R4's ceiling)
18
- shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
19
-
20
- THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
21
- `edit` = may also change the object's CONTENT.
22
-
23
- ⭐⭐ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β€” OWNER RULING R4, instruction 4 (*"Edit
24
- View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
25
- the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
26
- `RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
27
- are untouched β€” still the owner's or an admin's, for the reason given at that constant.
28
-
29
- β›” WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
30
- IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
31
- themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
32
- everyone"*. Taking those one at a time:
33
- * **SOLE OWNERSHIP IS BLOCKED β€” and now stated rather than incidental.** The owner is STICKY in
34
- `set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
35
- a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
36
- Ownership moves for nobody, by any path this registry offers.
37
- * **WIDENING IS BOUNDED, NOT BLOCKED, AND THAT IS R4's DELIBERATE COST.** A re-share may never
38
- exceed the role the re-sharer holds, and the STRICTER reading of that ships: an `edit` grantee
39
- may add somebody β€” `*` included β€” at `view`, and can never mint `edit` access for anyone.
40
- Handing out `edit` stays the owner's (or an admin's) alone. A `view` grantee still cannot share
41
- at all. `max_grantable_role` is that ceiling, and `routes_shares.put_share` is where it bites.
42
-
43
- β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
44
- owner, an unknown role string all resolve to None rather than to a default that opens something.
45
- [[aios-permissioning]]: no fail-open defaults, ever.
46
-
47
- ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
48
- session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
49
- (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
50
- callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
51
- NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
52
- """
53
- import core.store as store
54
-
55
- #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
56
- #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
57
- #: three separate buckets would make it three reads that can disagree about what a user can see.
58
- SHARES_KEY = 'object_shares'
59
-
60
- #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
61
- #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
62
- #:
63
- #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
64
- #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
65
- #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
66
- #: here is this tuple member. That is the point of one registry: the new object's WALL is written
67
- #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
68
- #: permission decision in a fourth file ([[one-evaluator-per-question]]).
69
- KINDS = ('view', 'folder', 'database', 'field')
70
-
71
- #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
72
- #: Spelled as a single character so it can never collide with a username (usernames are lower-case
73
- #: and non-empty by `core/users.py`, and are checked against this explicitly below).
74
- #:
75
- #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
76
- #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
77
- #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
78
- #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
79
- #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
80
- #: whole point:
81
- #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
82
- #: the sentence holds. `require_session` plus the topic's own gate run first, and the
83
- #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
84
- #: merged, so a grant can only narrow-or-equal what that account could already reach.
85
- #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
86
- #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
87
- #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
88
- #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
89
- #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
90
- #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
91
- #: every column β€” and this registry was the only wall behind it.
92
- #:
93
- #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
94
- #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
95
- #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
96
- #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
97
- #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
98
- #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
99
- #: every account in the tenant, and each of them still only sees what their own wall allows.
100
- #:
101
- #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
102
- #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
103
- #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
104
- #: [[two-gates-can-assert-opposite-things]].
105
- EVERYONE = '*'
106
-
107
- ROLES = ('view', 'edit')
108
-
109
- #: ⭐⭐ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β€” R4 / W40-T02, and it is `view` ALONE.
110
- #:
111
- #: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
112
- #: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
113
- #: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
114
- #: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
115
- #: in the same line β€” handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
116
- #: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
117
- #: the receiver's own module wall and row scope; a database grant admits an account to a database.
118
- #: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
119
- #:
120
- #: ⚠ ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
121
- #: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
122
- RESHARE_KINDS = ('view',)
123
-
124
- #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
125
- #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
126
- #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
127
- #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
128
- #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
129
- #: governs are named by the same string ([[one-question-two-normalizers]]).
130
- #:
131
- #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
132
- #: so the split below is unambiguous in both directions.
133
- FIELD_OID_SEP = ':'
134
-
135
-
136
- def field_oid(table_key, field_key):
137
- """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
138
- table = str(table_key or '').strip()
139
- field = str(field_key or '').strip()
140
- if not table or not field:
141
- raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
142
- 'A bare field key repeats across tables and would grant all of them')
143
- return f'{table}{FIELD_OID_SEP}{field}'
144
-
145
-
146
- def split_field_oid(oid):
147
- """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
148
-
149
- β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
150
- an id names must not fall back to "the one I happen to be looking at", which is how a grant
151
- on somebody else's column would be read as a grant on this one.
152
- """
153
- raw = str(oid or '')
154
- table, sep, field = raw.partition(FIELD_OID_SEP)
155
- if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
156
- return (None, None)
157
- return (table.strip(), field.strip())
158
-
159
-
160
- def _st(st):
161
- return st if st is not None else store
162
-
163
-
164
- def _check_kind(kind):
165
- k = str(kind or '').strip().lower()
166
- if k not in KINDS:
167
- raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
168
- f'This door refuses to invent a namespace from a typo.')
169
- return k
170
-
171
-
172
- def _clean_entries(entries):
173
- """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
174
-
175
- Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
176
- that just listed the people it is about to grant, so a rejected row must not abort the whole
177
- save β€” but an entry with an unknown ROLE must not be stored as something else's default
178
- either. Dropped, never coerced.
179
- """
180
- out, seen = [], set()
181
- for e in entries or ():
182
- if not isinstance(e, dict):
183
- continue
184
- user = str(e.get('user') or '').strip().lower()
185
- role = str(e.get('role') or '').strip().lower()
186
- if not user or role not in ROLES or user in seen:
187
- continue
188
- seen.add(user)
189
- out.append({'user': user, 'role': role})
190
- return out
191
-
192
-
193
- def grants(kind, oid, st=None):
194
- """`{'owner': str|None, 'entries': [{'user','role'}]}` β€” never raises on a junk bucket."""
195
- kind = _check_kind(kind)
196
- try:
197
- bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
198
- rec = bucket.get(str(oid)) or {}
199
- except Exception:
200
- return {'owner': None, 'entries': []}
201
- if not isinstance(rec, dict):
202
- return {'owner': None, 'entries': []}
203
- return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
204
- 'entries': _clean_entries(rec.get('entries'))}
205
-
206
-
207
- def set_grants(kind, oid, entries, owner=None, st=None):
208
- """REPLACE the grant set for one object. Returns the stored record.
209
-
210
- ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
211
- entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
212
- manage-access editor R10 asks for is exactly "here is the list now".
213
- """
214
- kind = _check_kind(kind)
215
- oid = str(oid)
216
- clean = _clean_entries(entries)
217
- owner_l = str(owner).strip().lower() if owner else None
218
-
219
- def _apply(data):
220
- by_kind = dict(data.get(kind) or {})
221
- prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
222
- # The owner is STICKY: set once, and a later save that omits it must not orphan the
223
- # object. An ownerless grant record cannot answer "who may re-share this", so every
224
- # administer check would fail closed and the object would become unmanageable.
225
- keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
226
- if prior.get('owner') else None)
227
- if not clean and not keep_owner:
228
- by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
229
- else:
230
- by_kind[oid] = {'owner': keep_owner, 'entries': clean}
231
- data[kind] = by_kind
232
- return data
233
-
234
- _st(st).update(SHARES_KEY, _apply, flush='async')
235
- return grants(kind, oid, st=st)
236
-
237
-
238
- def role_for(kind, oid, user, is_admin=False, st=None):
239
- """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
240
-
241
- An ADMIN reads as `'owner'`: an admin who could not administer an object could not
242
- administer the tenant either, which is `table_store._may_administer`'s existing rule and is
243
- kept identical here so the two cannot disagree about the same view.
244
- """
245
- user = str(user or '').strip().lower()
246
- if not user:
247
- return None
248
- rec = grants(kind, oid, st=st)
249
- if is_admin or (rec['owner'] and rec['owner'] == user):
250
- return 'owner'
251
- best = None
252
- for e in rec['entries']:
253
- if e['user'] == user or e['user'] == EVERYONE:
254
- # The STRONGER of the two wins when both a personal and an everyone grant exist:
255
- # naming somebody explicitly is how you RAISE them above the room, so an
256
- # everyone-view + alice-edit pair must leave alice editing.
257
- if e['role'] == 'edit':
258
- return 'edit'
259
- best = best or 'view'
260
- return best
261
-
262
-
263
- def may_see(kind, oid, user, is_admin=False, st=None):
264
- return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
265
-
266
-
267
- def may_edit(kind, oid, user, is_admin=False, st=None):
268
- return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
269
-
270
-
271
- def may_administer(kind, oid, user, is_admin=False, st=None):
272
- """May this caller change WHO ELSE reaches the object, and revoke them?
273
-
274
- ⭐⭐ R4 / W40-T02 β€” AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
275
- instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
276
- reverses this function's old owner-or-admin rule for exactly one kind; the module note says
277
- which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
278
-
279
- β›” ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
280
- means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
281
- hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
282
- a caller consults both ([[one-evaluator-per-question]]).
283
-
284
- β›” THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
285
- testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
286
- """
287
- role = role_for(kind, oid, user, is_admin=is_admin, st=st)
288
- if role == 'owner':
289
- return True
290
- return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
291
-
292
-
293
- def max_grantable_role(kind, oid, user, is_admin=False, st=None):
294
- """The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
295
-
296
- `'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
297
- Β· `None` for anybody who may not administer the object at all.
298
-
299
- ⭐⭐ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
300
- `edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
301
- exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
302
- answering both is exactly how the cap gets dropped by the next caller that only needs the door.
303
-
304
- β›” THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
305
- reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
306
- confer strictly less than the owner can*. This returns `'view'` β€” the second β€” because it is the
307
- FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
308
- `'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
309
- nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
310
-
311
- ⚠ THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
312
- load-bearing. `routes_shares.put_share` enforces it against the DELTA β€” a name arriving at
313
- `edit`, or an existing `view` grantee raised to it β€” never against every row of a body, because
314
- the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
315
- row included. The evidence for that is at the call site, where the body is.
316
- """
317
- role = role_for(kind, oid, user, is_admin=is_admin, st=st)
318
- if role == 'owner':
319
- return 'edit'
320
- if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
321
- return 'view'
322
- return None
323
-
324
-
325
- def shared_with(user, kind=None, st=None):
326
- """Every object id this user has been granted (excluding what they own).
327
-
328
- This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
329
- you made is not something shared *with* you, and listing it there would make the system
330
- folder a duplicate of the rail above it.
331
- """
332
- user = str(user or '').strip().lower()
333
- if not user:
334
- return {}
335
- try:
336
- data = _st(st).get(SHARES_KEY) or {}
337
- except Exception:
338
- return {}
339
- out = {}
340
- for k in ([_check_kind(kind)] if kind else KINDS):
341
- hits = []
342
- for oid, rec in (data.get(k) or {}).items():
343
- if not isinstance(rec, dict):
344
- continue
345
- owner = str(rec.get('owner') or '').strip().lower()
346
- if owner == user:
347
- continue
348
- for e in _clean_entries(rec.get('entries')):
349
- if e['user'] in (user, EVERYONE):
350
- hits.append(str(oid))
351
- break
352
- out[k] = sorted(hits)
353
- return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
354
-
355
-
356
- def granted_oids(kind, st=None):
357
- """Every object id of one `kind` that carries AT LEAST ONE grant entry β€” in ONE bucket read.
358
-
359
- ⭐ W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
360
- not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
361
- EXCLUDES what the caller owns β€” so it structurally cannot answer the other question a share
362
- mark asks: *does this object have grants at all*. That question has no viewer in it, which is
363
- why this is a separate function rather than a flag bolted onto `shared_with`.
364
-
365
- ⚠ ONE READ, NOT N β€” and that is the whole reason this exists rather than a loop at the caller.
366
- The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
367
- on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
368
- full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
369
- set the caller tests in O(1).
370
-
371
- β›” AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
372
- revoke β€” the owner is sticky, deliberately, see its own note β€” so testing for the record's
373
- mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
374
- exactly the revoke case, so it is the difference between this being right and being decorative
375
- noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
376
- cannot mark an object either.
377
-
378
- β›” FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β€” no
379
- marks β€” never a default that claims something is shared. An unknown KIND still RAISES, exactly
380
- as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
381
- store, and swallowing it into "nothing is shared" would hide a caller that never works.
382
- """
383
- kind = _check_kind(kind)
384
- try:
385
- by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
386
- rows = list(by_kind.items())
387
- except Exception:
388
- return set()
389
- return {str(oid) for oid, rec in rows
390
- if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
391
-
392
-
393
- def drop_objects(pairs, st=None):
394
- """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
395
-
396
- A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
397
- into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
398
- transaction for the whole sweep β€” a table delete drops its database grant plus a view
399
- grant per view that lived in its bucket."""
400
- want = {}
401
- for kind, oid in pairs or ():
402
- want.setdefault(_check_kind(kind), set()).add(str(oid))
403
- if not want:
404
- return
405
-
406
- def _apply(data):
407
- for kind, oids in want.items():
408
- by_kind = data.get(kind)
409
- if isinstance(by_kind, dict):
410
- for oid in oids:
411
- by_kind.pop(oid, None)
412
- return data
413
-
414
- _st(st).update(SHARES_KEY, _apply, flush='async')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """core/shares.py β€” ONE grant registry for every shareable object (wave 20, owner ruling R10).
2
+
3
+ WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
4
+ already use** (specific users or everyone; role = view | edit), plus one manage-access editor
5
+ that can add or revoke people later, on any of the three.
6
+
7
+ WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
8
+ (`core/table_store.py`) and that stays β€” moving it would rewrite every stored view for no gain.
9
+ But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
10
+ definition; giving each its own grant field would put the same three-line permission decision in
11
+ three files owned by two sessions, which is how the three drift. One registry, one predicate,
12
+ three callers.
13
+
14
+ shares.set_grants(kind, oid, entries, owner=…, granter=…, st=…) # replaces the whole set
15
+ shares.grants(kind, oid, st=…) # -> {'owner': str, 'entries': [...]}
16
+ shares.role_for(kind, oid, user, is_admin=…, st=…) # -> 'owner'|'edit'|'view'|None
17
+ shares.max_grantable_role(kind, oid, user, …) # -> 'edit'|'view'|None (R4's ceiling)
18
+ shares.shared_with(user, kind=…, st=…) # -> [oid] this user was granted
19
+
20
+ THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
21
+ `edit` = may also change the object's CONTENT.
22
+
23
+ ⭐⭐ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β€” OWNER RULING R4, instruction 4 (*"Edit
24
+ View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
25
+ the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
26
+ `RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
27
+ are untouched β€” still the owner's or an admin's, for the reason given at that constant.
28
+
29
+ β›” WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
30
+ IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
31
+ themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
32
+ everyone"*. Taking those one at a time:
33
+ * **SOLE OWNERSHIP IS BLOCKED β€” and now stated rather than incidental.** The owner is STICKY in
34
+ `set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
35
+ a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
36
+ Ownership moves for nobody, by any path this registry offers.
37
+ * **WIDENING IS NOW BLOCKED TOO β€” OWNER RULING 2026-08-24, D-473.** β›” THE SENTENCE HERE USED
38
+ TO PRICE THIS AS A COST R4 ACCEPTED: *"an `edit` grantee may add somebody β€” `*` included β€” at
39
+ `view`"*. Measured and reported as such, and the owner's answer was *"no a re-sharer can only
40
+ share to specific people"*. So a re-sharer may name PEOPLE and may not reach `EVERYONE`, and
41
+ the second half of the old fear is closed rather than merely capped. The ROLE ceiling below
42
+ still stands beside it β€” they are two different questions and both are asked.
43
+ * **THE ROLE CEILING SURVIVES UNCHANGED.** A re-share may never exceed the role the re-sharer
44
+ holds, and the STRICTER reading of that ships: an `edit` grantee may add somebody at `view`,
45
+ and can never mint `edit` access for anyone. Handing out `edit` stays the owner's (or an
46
+ admin's) alone. A `view` grantee still cannot share at all. `max_grantable_role` is that
47
+ ceiling, and `routes_shares.put_share` is where it bites.
48
+ * **AND REVOCATION IS BOUNDED BY PROVENANCE β€” OWNER RULING 2026-08-24, D-474.** *"a re-sharer
49
+ can only unshare the people it shared to"*. R4 bounded the role a re-sharer may HAND OUT and
50
+ said nothing about what they may TAKE AWAY, so an `edit` grantee could `PUT []` and leave the
51
+ view shared with nobody. Answering that needs a fact the record did not carry, which is what
52
+ the `by` member on each entry is (`_clean_entries` / `set_grants`' `granter`). β›” An entry
53
+ with NO `by` β€” every grant written before this change β€” is the OWNER's to revoke and nobody
54
+ else's: a re-sharer must never revoke a grant they cannot prove they made.
55
+
56
+ β›” AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β€” junk in the bucket, a missing
57
+ owner, an unknown role string all resolve to None rather than to a default that opens something.
58
+ [[aios-permissioning]]: no fail-open defaults, ever.
59
+
60
+ ⚠ THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
61
+ session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
62
+ (`core.store`) is tenant #0 and exists for the same reason it does everywhere else β€” the ~28
63
+ callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
64
+ NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
65
+ """
66
+ import core.store as store
67
+
68
+ #: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
69
+ #: is a question across kinds β€” the "Shared with me" folder (R10) is exactly that query, and
70
+ #: three separate buckets would make it three reads that can disagree about what a user can see.
71
+ SHARES_KEY = 'object_shares'
72
+
73
+ #: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
74
+ #: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
75
+ #:
76
+ #: ⭐⭐ W38-T16 β€” `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
77
+ #: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
78
+ #: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
79
+ #: here is this tuple member. That is the point of one registry: the new object's WALL is written
80
+ #: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β€” never a fourth
81
+ #: permission decision in a fourth file ([[one-evaluator-per-question]]).
82
+ KINDS = ('view', 'folder', 'database', 'field')
83
+
84
+ #: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
85
+ #: Spelled as a single character so it can never collide with a username (usernames are lower-case
86
+ #: and non-empty by `core/users.py`, and are checked against this explicitly below).
87
+ #:
88
+ #: β›”β›” **AND WHAT THAT MEANS DEPENDS ON THE KIND β€” THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
89
+ #: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
90
+ #: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
91
+ #: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
92
+ #: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
93
+ #: whole point:
94
+ #: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β€”
95
+ #: the sentence holds. `require_session` plus the topic's own gate run first, and the
96
+ #: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
97
+ #: merged, so a grant can only narrow-or-equal what that account could already reach.
98
+ #: * `kind='database'` on a `ut_*` table β€” ⭐⭐ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
99
+ #: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
100
+ #: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
101
+ #: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
102
+ #: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
103
+ #: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β€” every row,
104
+ #: every column β€” and this registry was the only wall behind it.
105
+ #:
106
+ #: ⭐ WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
107
+ #: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
108
+ #: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β€” the same code that walls
109
+ #: `customer_data` β€” and a door that cannot apply them REFUSES rather than serving the lot. So a
110
+ #: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
111
+ #: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
112
+ #: every account in the tenant, and each of them still only sees what their own wall allows.
113
+ #:
114
+ #: ⚠ `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
115
+ #: no wave-36 fence β€” the audit's own fix was "say so at both", so one of the two is now stale.
116
+ #: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
117
+ #: [[two-gates-can-assert-opposite-things]].
118
+ EVERYONE = '*'
119
+
120
+ ROLES = ('view', 'edit')
121
+
122
+ #: ⭐⭐ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β€” R4 / W40-T02, and it is `view` ALONE.
123
+ #:
124
+ #: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
125
+ #: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
126
+ #: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
127
+ #: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
128
+ #: in the same line β€” handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
129
+ #: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
130
+ #: the receiver's own module wall and row scope; a database grant admits an account to a database.
131
+ #: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
132
+ #:
133
+ #: ⚠ ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
134
+ #: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
135
+ RESHARE_KINDS = ('view',)
136
+
137
+
138
+ class GrantsChanged(Exception):
139
+ """A grant write was decided against a record that is no longer the stored one.
140
+
141
+ Raised by `set_grants` when its `expect` does not match what is in the store at write time.
142
+ The door turns it into a 409 rather than a 500: it is not an error in the request, it is two
143
+ people editing the same sharing list at once, and the honest answer is to say so.
144
+ """
145
+
146
+
147
+ #: β›”β›” A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
148
+ #: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
149
+ #: grant stored under it would admit a grantee to every `notes` column in the tenant at once β€”
150
+ #: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
151
+ #: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
152
+ #: governs are named by the same string ([[one-question-two-normalizers]]).
153
+ #:
154
+ #: ⚠ A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
155
+ #: so the split below is unambiguous in both directions.
156
+ FIELD_OID_SEP = ':'
157
+
158
+
159
+ def field_oid(table_key, field_key):
160
+ """`"<table_key>:<field_key>"` β€” the share id of ONE column on ONE database."""
161
+ table = str(table_key or '').strip()
162
+ field = str(field_key or '').strip()
163
+ if not table or not field:
164
+ raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
165
+ 'A bare field key repeats across tables and would grant all of them')
166
+ return f'{table}{FIELD_OID_SEP}{field}'
167
+
168
+
169
+ def split_field_oid(oid):
170
+ """`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
171
+
172
+ β›” FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
173
+ an id names must not fall back to "the one I happen to be looking at", which is how a grant
174
+ on somebody else's column would be read as a grant on this one.
175
+ """
176
+ raw = str(oid or '')
177
+ table, sep, field = raw.partition(FIELD_OID_SEP)
178
+ if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
179
+ return (None, None)
180
+ return (table.strip(), field.strip())
181
+
182
+
183
+ def _st(st):
184
+ return st if st is not None else store
185
+
186
+
187
+ def _check_kind(kind):
188
+ k = str(kind or '').strip().lower()
189
+ if k not in KINDS:
190
+ raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
191
+ f'This door refuses to invent a namespace from a typo.')
192
+ return k
193
+
194
+
195
+ def _clean_entries(entries):
196
+ """Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}] β€” plus an
197
+ OPTIONAL `'by'`, present only when the record already carried one.
198
+
199
+ Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
200
+ that just listed the people it is about to grant, so a rejected row must not abort the whole
201
+ save β€” but an entry with an unknown ROLE must not be stored as something else's default
202
+ either. Dropped, never coerced.
203
+
204
+ ⭐⭐ D-474 β€” `by` IS PROVENANCE: THE USERNAME OF WHOEVER PLACED THIS GRANT. Owner ruling
205
+ 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. That question has no
206
+ answer in a record that stores only WHO HOLDS the grant, so the record grew a third member and
207
+ this normaliser is where it survives the round trip (`grants` reads every stored entry back
208
+ through here, so a member this function drops is a member the door can never consult).
209
+
210
+ β›”β›” AND THIS FUNCTION STAYS A PURE NORMALISER: IT NEVER INVENTS A `by`, AND IT NEVER
211
+ VALIDATES ONE. That is not tidiness, it is the whole security property. `set_grants` is handed
212
+ a caller-supplied list, so a `by` arriving HERE may be forged β€” and the merge inside
213
+ `set_grants._apply` therefore OVERWRITES it in both directions from the PRIOR record and the
214
+ `granter`, never from the payload. Were this function to treat a submitted `by` as
215
+ authoritative, a re-sharer could PUT `{user: victim, role: view, by: <themselves>}` to
216
+ re-stamp somebody else's grant as their own, then PUT again omitting the victim, and the wall
217
+ at the door would be decoration. Normalise the SHAPE here; decide the VALUE there.
218
+
219
+ ⚠ KEY ORDER IS `user, role, by` AND `by` IS ABSENT RATHER THAN `None` WHEN THERE IS NONE.
220
+ Absence is what keeps every pre-existing record byte-identical after this change (an added
221
+ `by: None` would rewrite the whole bucket on the next save, for nothing), and it is what
222
+ `aios-web/api/verify_field_permissions.py` reads when it compares `tuple(entry.values())`
223
+ against a 2-tuple.
224
+ """
225
+ out, seen = [], set()
226
+ for e in entries or ():
227
+ if not isinstance(e, dict):
228
+ continue
229
+ user = str(e.get('user') or '').strip().lower()
230
+ role = str(e.get('role') or '').strip().lower()
231
+ if not user or role not in ROLES or user in seen:
232
+ continue
233
+ seen.add(user)
234
+ row = {'user': user, 'role': role}
235
+ by = str(e.get('by') or '').strip().lower()
236
+ if by:
237
+ row['by'] = by
238
+ out.append(row)
239
+ return out
240
+
241
+
242
+ def grants(kind, oid, st=None):
243
+ """`{'owner': str|None, 'entries': [{'user','role','by'?}]}` β€” never raises on a junk bucket.
244
+
245
+ ⭐⭐ D-474 β€” `by` RIDES THE READ, because the DOOR is what has to consult it. The revocation
246
+ wall lives in `routes_shares.put_share` (it needs the session, which this layer does not
247
+ have), and a wall cannot read a member this function strips. `_clean_entries` carries it,
248
+ so every reader here sees it and no reader has to reach into the raw bucket.
249
+
250
+ ⚠ THE COST, STATED RATHER THAN DISCOVERED: `GET /api/v1/share/{kind}/{oid}` returns this
251
+ record verbatim, so anyone who may read a grant list now also learns WHO ADDED each person,
252
+ a `view` grantee included. That is a mild widening of *"who has this?"* into *"who let them
253
+ in?"*. It is accepted deliberately: the alternative is a second, provenance-stripped read
254
+ path, which is a second answer to one question and is how the two come apart
255
+ ([[one-evaluator-per-question]]).
256
+ """
257
+ kind = _check_kind(kind)
258
+ try:
259
+ bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
260
+ rec = bucket.get(str(oid)) or {}
261
+ except Exception:
262
+ return {'owner': None, 'entries': []}
263
+ if not isinstance(rec, dict):
264
+ return {'owner': None, 'entries': []}
265
+ return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
266
+ 'entries': _clean_entries(rec.get('entries'))}
267
+
268
+
269
+ def set_grants(kind, oid, entries, owner=None, st=None, granter=None, expect=None):
270
+ """REPLACE the grant set for one object. Returns the stored record.
271
+
272
+ ⚠ REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
273
+ entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
274
+ manage-access editor R10 asks for is exactly "here is the list now".
275
+
276
+ ⭐⭐ D-474 β€” `granter` IS WHO IS DOING THIS SAVE, AND IT IS THE ONLY SOURCE OF A NEW `by`.
277
+ Owner ruling 2026-08-24: *"a re-sharer can only unshare the people it shared to"*. The wall
278
+ is at the door (`routes_shares.put_share`), but the FACT it consults can only be recorded
279
+ here, at the write.
280
+
281
+ β›”β›” AND THE STAMP IS DECIDED INSIDE `_apply`, NOT ABOVE IT, BECAUSE `_apply` IS THE ONLY
282
+ PLACE THE PRIOR RECORD IS VISIBLE. Two rules, and the first is the one that matters:
283
+ * a user ALREADY in the prior record keeps their stored `by` VERBATIM. Never re-stamped β€”
284
+ a re-save would otherwise transfer provenance to whoever saved last, so every re-sharer
285
+ would silently inherit the right to revoke everybody the owner had ever added, simply by
286
+ pressing Save. That is the ruling inverted, arriving by accident.
287
+ * a user who is NEW to the record takes `by = granter`, or carries NO `by` at all when no
288
+ granter was named.
289
+
290
+ β›” IN BOTH ARMS THE VALUE IS OVERWRITTEN FROM (prior, granter) AND NEVER READ OFF THE
291
+ SUBMITTED ENTRY. `_clean_entries` preserves a submitted `by` because a STORED one has to
292
+ survive the read; a caller-supplied one is untrusted input. Filling in only a MISSING `by`
293
+ would leave the two-step forgery open: PUT `{user: victim, by: <me>}` to claim provenance,
294
+ then PUT again omitting the victim.
295
+
296
+ ⭐ `granter` DEFAULTS TO `None`, AND THAT IS WHAT MAKES THIS SHAPE CHANGE SAFE. Every
297
+ non-door caller β€” `core.field_permissions` (promote + reconcile), `modules.product_data`'s
298
+ Image field, `core.grid_events`' cleanup on delete, the gates β€” replaces a whole grant set
299
+ with no caller identity in hand, and each keeps producing EXACTLY the record it produced
300
+ before this change: no `by`, no new bytes, no behaviour moved. A grant with no `by` is one
301
+ nobody can prove they made, and the door treats it as the owner's alone to revoke
302
+ (fail-closed). ⚠ A future edit that defaults this to a session, or stamps it from the entry,
303
+ deletes that property without touching a line the compiler can complain about.
304
+
305
+ ⚠ `_apply` MAY RUN MORE THAN ONCE. `store._update_locked` re-applies the mutation on a
306
+ `parent_commit` rebase, so the merge builds FRESH dicts from `clean` on every invocation
307
+ rather than mutating it in place; a second pass must see the same untouched input, and it
308
+ must be free to reclassify a user who was "new" on the first pass and is "prior" on the
309
+ second (exactly what a rebase against another writer's save looks like).
310
+ """
311
+ kind = _check_kind(kind)
312
+ oid = str(oid)
313
+ clean = _clean_entries(entries)
314
+ owner_l = str(owner).strip().lower() if owner else None
315
+ granter_l = str(granter).strip().lower() if granter else None
316
+
317
+ def _apply(data):
318
+ by_kind = dict(data.get(kind) or {})
319
+ # β›”β›” COMPARE-AND-SET, AND IT IS A SECURITY BOUNDARY RATHER THAN A TIDINESS ONE.
320
+ # `routes_shares.put_share` reads the record ONCE, decides against it (may this caller
321
+ # remove that person, raise that role, add that audience), and only then calls this. The
322
+ # store re-reads `prior` fresh in here, so between the decision and the write another
323
+ # save can land and the decision is being applied to a record it never saw.
324
+ #
325
+ # Measured by a wave-40 adversarial probe, and it needs no attacker timing: the owner and
326
+ # a re-sharer both have Manage Access open and both press Save. The re-sharer's PUT was
327
+ # ACCEPTED with 200 and the grant the owner had just added was gone, with no error on
328
+ # either screen. That is D-474's wall failing open in the one window where two people are
329
+ # actually editing the same thing.
330
+ #
331
+ # ⚠ THE LOST UPDATE PREDATES THE WALL -- REPLACE semantics plus read-modify-write have
332
+ # always meant last-write-wins here. What is new is that a PERMISSION decision now rests
333
+ # on that read. So the caller states what it decided against, and a write that would land
334
+ # on anything else is refused rather than merged.
335
+ #
336
+ # `expect=None` keeps every existing caller byte-identical: the server-derived callers
337
+ # (`field_permissions`, `product_data`, the `[]` revokes) are not deciding anything about
338
+ # a person and have nothing to compare.
339
+ if expect is not None:
340
+ _now = [(e['user'], e['role']) for e in _clean_entries(
341
+ (by_kind.get(oid) or {}).get('entries') if isinstance(by_kind.get(oid), dict)
342
+ else [])]
343
+ if _now != [(e['user'], e['role']) for e in _clean_entries(expect)]:
344
+ raise GrantsChanged(
345
+ 'this item was shared with somebody else while you were editing, so nothing '
346
+ 'was saved. Reopen the sharing panel to see who has access now, then make '
347
+ 'your change again.')
348
+ prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
349
+ # Read the prior entries through the SAME normaliser every other reader uses, so "was
350
+ # this user already here" cannot be answered one way by the store and another by the
351
+ # door ([[one-question-two-normalizers]]).
352
+ prior_by = {e['user']: e.get('by') for e in _clean_entries(prior.get('entries'))}
353
+ merged = []
354
+ for e in clean:
355
+ row = {'user': e['user'], 'role': e['role']}
356
+ stamp = prior_by[e['user']] if e['user'] in prior_by else granter_l
357
+ if stamp:
358
+ row['by'] = stamp
359
+ merged.append(row)
360
+ # The owner is STICKY: set once, and a later save that omits it must not orphan the
361
+ # object. An ownerless grant record cannot answer "who may re-share this", so every
362
+ # administer check would fail closed and the object would become unmanageable.
363
+ keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
364
+ if prior.get('owner') else None)
365
+ if not merged and not keep_owner:
366
+ by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
367
+ else:
368
+ by_kind[oid] = {'owner': keep_owner, 'entries': merged}
369
+ data[kind] = by_kind
370
+ return data
371
+
372
+ _st(st).update(SHARES_KEY, _apply, flush='async')
373
+ return grants(kind, oid, st=st)
374
+
375
+
376
+ def role_for(kind, oid, user, is_admin=False, st=None):
377
+ """`'owner'` | `'edit'` | `'view'` | `None` β€” the caller's effective role, fail-closed.
378
+
379
+ An ADMIN reads as `'owner'`: an admin who could not administer an object could not
380
+ administer the tenant either, which is `table_store._may_administer`'s existing rule and is
381
+ kept identical here so the two cannot disagree about the same view.
382
+ """
383
+ user = str(user or '').strip().lower()
384
+ if not user:
385
+ return None
386
+ rec = grants(kind, oid, st=st)
387
+ if is_admin or (rec['owner'] and rec['owner'] == user):
388
+ return 'owner'
389
+ best = None
390
+ for e in rec['entries']:
391
+ if e['user'] == user or e['user'] == EVERYONE:
392
+ # The STRONGER of the two wins when both a personal and an everyone grant exist:
393
+ # naming somebody explicitly is how you RAISE them above the room, so an
394
+ # everyone-view + alice-edit pair must leave alice editing.
395
+ if e['role'] == 'edit':
396
+ return 'edit'
397
+ best = best or 'view'
398
+ return best
399
+
400
+
401
+ def may_see(kind, oid, user, is_admin=False, st=None):
402
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
403
+
404
+
405
+ def may_edit(kind, oid, user, is_admin=False, st=None):
406
+ return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
407
+
408
+
409
+ def may_administer(kind, oid, user, is_admin=False, st=None):
410
+ """May this caller change WHO ELSE reaches the object, and revoke them?
411
+
412
+ ⭐⭐ R4 / W40-T02 β€” AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
413
+ instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
414
+ reverses this function's old owner-or-admin rule for exactly one kind; the module note says
415
+ which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
416
+
417
+ β›” ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
418
+ means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
419
+ hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
420
+ a caller consults both ([[one-evaluator-per-question]]).
421
+
422
+ β›” THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
423
+ testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
424
+ """
425
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
426
+ if role == 'owner':
427
+ return True
428
+ return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
429
+
430
+
431
+ def max_grantable_role(kind, oid, user, is_admin=False, st=None):
432
+ """The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
433
+
434
+ `'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
435
+ Β· `None` for anybody who may not administer the object at all.
436
+
437
+ ⭐⭐ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
438
+ `edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
439
+ exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
440
+ answering both is exactly how the cap gets dropped by the next caller that only needs the door.
441
+
442
+ β›” THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
443
+ reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
444
+ confer strictly less than the owner can*. This returns `'view'` β€” the second β€” because it is the
445
+ FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
446
+ `'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
447
+ nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
448
+
449
+ ⚠ THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
450
+ load-bearing. `routes_shares.put_share` enforces it against the DELTA β€” a name arriving at
451
+ `edit`, or an existing `view` grantee raised to it β€” never against every row of a body, because
452
+ the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
453
+ row included. The evidence for that is at the call site, where the body is.
454
+
455
+ β›”β›” AND IT IS ONE OF THREE BOUNDS ON A RE-SHARE, NOT THE BOUND. Owner ruling 2026-08-24 added
456
+ two more, and both live at the door because both need the SESSION: `_audience_added` (D-473,
457
+ a re-sharer may name people and may not reach `EVERYONE`) and `_unremovable` (D-474, a
458
+ re-sharer may revoke only what their own `by` stamp says they granted). ⚠ A reader who takes
459
+ this function for the whole ceiling will widen the other two by leaving them alone β€” which is
460
+ exactly how R4 shipped with an audience hole and a revocation hole under a docstring that
461
+ read like a complete account of the limits.
462
+ """
463
+ role = role_for(kind, oid, user, is_admin=is_admin, st=st)
464
+ if role == 'owner':
465
+ return 'edit'
466
+ if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
467
+ return 'view'
468
+ return None
469
+
470
+
471
+ def shared_with(user, kind=None, st=None):
472
+ """Every object id this user has been granted (excluding what they own).
473
+
474
+ This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
475
+ you made is not something shared *with* you, and listing it there would make the system
476
+ folder a duplicate of the rail above it.
477
+ """
478
+ user = str(user or '').strip().lower()
479
+ if not user:
480
+ return {}
481
+ try:
482
+ data = _st(st).get(SHARES_KEY) or {}
483
+ except Exception:
484
+ return {}
485
+ out = {}
486
+ for k in ([_check_kind(kind)] if kind else KINDS):
487
+ hits = []
488
+ for oid, rec in (data.get(k) or {}).items():
489
+ if not isinstance(rec, dict):
490
+ continue
491
+ owner = str(rec.get('owner') or '').strip().lower()
492
+ if owner == user:
493
+ continue
494
+ for e in _clean_entries(rec.get('entries')):
495
+ if e['user'] in (user, EVERYONE):
496
+ hits.append(str(oid))
497
+ break
498
+ out[k] = sorted(hits)
499
+ return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
500
+
501
+
502
+ def granted_oids(kind, st=None):
503
+ """Every object id of one `kind` that carries AT LEAST ONE grant entry β€” in ONE bucket read.
504
+
505
+ ⭐ W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
506
+ not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
507
+ EXCLUDES what the caller owns β€” so it structurally cannot answer the other question a share
508
+ mark asks: *does this object have grants at all*. That question has no viewer in it, which is
509
+ why this is a separate function rather than a flag bolted onto `shared_with`.
510
+
511
+ ⚠ ONE READ, NOT N β€” and that is the whole reason this exists rather than a loop at the caller.
512
+ The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
513
+ on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
514
+ full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
515
+ set the caller tests in O(1).
516
+
517
+ β›” AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
518
+ revoke β€” the owner is sticky, deliberately, see its own note β€” so testing for the record's
519
+ mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
520
+ exactly the revoke case, so it is the difference between this being right and being decorative
521
+ noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
522
+ cannot mark an object either.
523
+
524
+ β›” FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β€” no
525
+ marks β€” never a default that claims something is shared. An unknown KIND still RAISES, exactly
526
+ as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
527
+ store, and swallowing it into "nothing is shared" would hide a caller that never works.
528
+ """
529
+ kind = _check_kind(kind)
530
+ try:
531
+ by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
532
+ rows = list(by_kind.items())
533
+ except Exception:
534
+ return set()
535
+ return {str(oid) for oid, rec in rows
536
+ if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
537
+
538
+
539
+ def drop_objects(pairs, st=None):
540
+ """Remove whole grant RECORDS, owner husk included β€” wave 21, item 6a (C3).
541
+
542
+ A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
543
+ into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
544
+ transaction for the whole sweep β€” a table delete drops its database grant plus a view
545
+ grant per view that lived in its bucket."""
546
+ want = {}
547
+ for kind, oid in pairs or ():
548
+ want.setdefault(_check_kind(kind), set()).add(str(oid))
549
+ if not want:
550
+ return
551
+
552
+ def _apply(data):
553
+ for kind, oids in want.items():
554
+ by_kind = data.get(kind)
555
+ if isinstance(by_kind, dict):
556
+ for oid in oids:
557
+ by_kind.pop(oid, None)
558
+ return data
559
+
560
+ _st(st).update(SHARES_KEY, _apply, flush='async')