OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / b87f718

docs: rewrite README + add API/SCHEMA reference
The old README described the pre-#35 single-shared-autobase design as current and every API example used method names/signatures that no longer exist (createOrg/addMember({...})/grant vs create/addMember/ createGrant), plus a dangling reference to a removed test. Rewrite against the actual code: - per-org-autobase architecture (#35, landed) + peer vs mirror modes - correct constructor ({namespace, extraSchemas}) and method signatures - the three distinct role vocabularies (member/grant/pairing) - docs/API.md — full method reference from lib/hyper-orgs.js - docs/SCHEMA.md — ops + view keys from lib/schema.js, flagging the latent invite/access-request ops not surfaced as class methods Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b87f718cacb368fff84de077858f7ff9e3d685cf
by plankatron <fleeky@gmail.com> · 2026-06-12T19:51:08.000Z

Changed files (3) +475 -38

README.md +164 -38
……@@ -1,65 +1,191 @@
11# hyper-orgs
22
3P2P organization management — a `hyper-rooms` schema for orgs,
4members, resources (repos/datasets/whatever), grants, invites, and
5access requests.
3P2P organization management built on [`hyper-rooms`](../hyper-rooms). It
4provides orgs (user or group namespaces), members, resources
5(repos/datasets/anything name-addressable), permission grants, and
6P2P join/invite — all replicated peer-to-peer with no server.
67
7One Autobase per org (a Room). Members are autobase writers
8(role-aware: owner = indexer; writer = plain). Schema validates ops
9and applies them to a Hyperbee view keyed by `org:<id>`,
10`member:<orgId>:<userId>`, `grant:<orgId>:<resourceId>:<userId>`, etc.
8It is the access-control and namespace layer for **`pear-git-v3`**: the
9daemon mounts one `HyperOrgs` instance per identity and uses it to decide
10who may push to which repo.
1111
12Used by `pear-git-v3` (the daemon mounts a `HyperOrgs` instance per
13identity).
12## Architecture — one autobase per org
13
14Each org is its **own** [`hyper-rooms`](../hyper-rooms) `Room` (a dedicated
15Autobase + Hyperbee view), **not** one shared autobase for every org. This
16is the post-mortem fix for issue #35: a peer that joins one org replicates
17**only** that org's autobase, so unrelated orgs never reach its disk. (An
18earlier single-shared-autobase design leaked 97 repos to a peer that joined
19one org — that is what this layout prevents.)
20
21A `HyperOrgs` instance tracks the orgs it has created or joined in an
22in-memory `Map`, plus a **local append-only index core** (`orgs-index`,
23*not* replicated) recording `{ name, bootstrapKey, role, created }` per org
24so `ready()` can re-open every tracked org `Room` after a restart.
25
26Org data (members, resources, grants) lives in that org's own view, keyed
27`org:<name>`, `grant:<grantId>`, etc. — see [docs/SCHEMA.md](docs/SCHEMA.md).
28
29### Two replication modes
30
31| Mode | How | Scope | API |
32|------|-----|-------|-----|
33| **Peer** | Join specific orgs via invite | Only invited orgs replicate (isolated) | `createOrgInvite` / `joinOrg` |
34| **Mirror** | Mount *every* org from a primary's key list | Full replica of all orgs | `listOrgKeys` / `mountOrg` |
35
36Peer mode is what enforces scope isolation; mirror mode is for a sister
37daemon that intentionally replicates the whole forge.
38
39## Install
40
41```bash
42npm install # depends on ../hyper-rooms (file: link)
43```
44
45`hyper-orgs` is ESM (`"type": "module"`) and runs on Node or Bare.
1446
1547## Usage
1648
1749```js
50import Corestore from 'corestore'
1851import { HyperOrgs } from 'hyper-orgs'
1952
20const orgs = new HyperOrgs(corestore, {
21 bootstrapKey: existingOrgsKey || null
22})
53const store = new Corestore('./store')
54const orgs = new HyperOrgs(store, { namespace: 'orgs' })
2355await orgs.ready()
2456
25await orgs.createOrg({ id: 'acme', name: 'Acme Inc.', ownerId: alice })
26await orgs.addMember('acme', { userId: bob, role: 'writer' })
27await orgs.grant('acme', { resourceId: 'repo:foo', userId: bob, perm: 'push' })
57// Create a group org and a personal (user) namespace
58await orgs.create('acme', 'group', { displayName: 'Acme Inc.' })
59await orgs.create('alice', 'user', { userId: aliceKeyHex })
60
61// Members (group orgs only) — role is 'admin' | 'member'
62await orgs.addMember('acme', bobKeyHex, 'member')
63
64// Resources — a resource lives in exactly one org
65await orgs.addResource('acme', 'repo:widgets')
66
67// Grants — permission is 'write' | 'moderator' | 'owner'
68await orgs.createGrant({
69 subjectType: 'user',
70 subjectId: bobKeyHex,
71 grantType: 'group', // 'group' (whole org) or 'resource' (one repo)
72 groupName: 'acme',
73 permission: 'write'
74})
75
76// Access check (resolves resource → owning org → grants)
77const ok = await orgs.checkAccess({
78 userId: bobKeyHex,
79 resourceName: 'repo:widgets',
80 requiredPermission: 'write'
81}) // → true
82```
83
84> The constructor takes `{ namespace, extraSchemas }` — there is **no**
85> `bootstrapKey` option. Per-org bootstrap keys are minted per `Room`
86> internally; you obtain them via `listOrgKeys()` / `createOrgInvite()`.
87
88### P2P join (peer mode, blind-pairing)
89
90Joining requires an injected shared `Hyperswarm` (invites travel over the
91swarm, Keet-style — there is no HTTP path). The owner issues an invite for
92one org; a joiner pairs and, for `contributor`+, is added as an autobase
93**writer during pairing**; `reader` stays a read-only mirror.
94
95```js
96// Owner side
97const { invitation, descriptor, close } =
98 await orgs.createOrgInvite('acme', { role: 'contributor', swarm })
99
100// Joiner side (separate machine / store, same swarm bootstrap)
101const { name, role } = await joiner.joinOrg(invitation, { swarm })
102// → mounts ONLY 'acme'; other orgs never replicate to the joiner
28103```
29104
105Outstanding invites stay live until `close()`. `resumeOrgPairing(name,
106descriptor, swarm)` re-registers a persisted invite after a restart.
107
108The canonical, runnable end-to-end flow (contributor becomes writer, reader
109stays read-only, scope isolation, multi-org join) is
110`test/org-pairing.test.js`.
111
112### Mirror mode (full replica)
113
114```js
115// Primary exposes its org keys (over RPC in the daemon)
116const keys = primary.listOrgKeys() // [{ name, bootstrapKey, role, created }]
117
118// Mirror mounts every org by key — no invite ceremony
119for (const k of keys) await mirror.mountOrg(k.name, k.bootstrapKey, 'reader')
120```
121
122See `test/mirror-mode.test.js`.
123
124## Three role/permission vocabularies (do not conflate)
125
126| Vocabulary | Values | Where | Meaning |
127|------------|--------|-------|---------|
128| **Member role** | `admin`, `member` | `org.members[].role` | Group membership |
129| **Grant permission** | `write` < `moderator` < `owner` | `grant:*` records | Access level (levels 1/2/3) checked by `checkAccess` |
130| **Pairing role** | `reader`, `contributor` | `createOrgInvite`/`joinOrg` | hyper-rooms blind-pairing: `contributor`+ → autobase writer, `reader` → read-only |
131
132These are independent. A pairing `role` controls whether the joiner becomes
133an autobase *writer*; it does **not** create a member record or a grant.
134
135## API
136
137Full reference: **[docs/API.md](docs/API.md)**. Quick map:
138
139- **Lifecycle:** `ready()`, `close()`, `sync()`, `replicate(conn)`
140- **Orgs:** `create`, `get`, `update`, `delete`, `list`
141- **Members:** `addMember`, `removeMember`, `getUserOrgs`
142- **Resources:** `addResource`, `removeResource`, `moveResource`
143- **Grants / access:** `createGrant`, `revokeGrant`, `getGrant`, `listGrants`, `checkAccess`
144- **Invites / join:** `createOrgInvite`, `joinOrg`, `resumeOrgPairing`, `mountOrg`, `listOrgKeys`
145- **Per-org accessors:** `getOrgBase(name)`, `getOrgRoom(name)`, `base` (single-org convenience — throws if >1 tracked)
146- **Writers (mirror HA):** `addWriter`, `removeWriter`, `listWriters`
147
30148## Schema
31149
32See `lib/schema.js` for the full op list. Reserved names guard the
33namespace; `validateName` is the canonical name check.
150The org `Room` view and op set are documented in
151**[docs/SCHEMA.md](docs/SCHEMA.md)**. `lib/schema.js` is the source of
152truth; `RESERVED_NAMES` and `validateName` guard the org-name namespace.
153
154> Note: the schema also defines bearer-invite and access-request ops
155> (`create-invite`/`consume-invite`, `create-access-request` /
156> `approve-access-request` / `deny-access-request`) and their view keys.
157> These are **not yet surfaced as `HyperOrgs` methods** — current join/grant
158> flows use blind-pairing invites instead. They are latent capability in the
159> view, available to a consumer that appends the ops directly.
34160
35161## Tests
36162
37163```bash
38npm test
164npm test # brittle test/*.test.js
39165```
40166
41## Status — per-org-autobase refactor (issue #35)
42
43The current implementation stores ALL orgs in ONE autobase. The
44README's "one Autobase per org" description is the post-refactor
45intent, not today's reality. A scope-peer attempt in `pear-git-v3`
46hit this directly (97 repos accidentally synced); see the post-mortem
47at `pear-git-v3` issue #35.
167Coverage: org/member/resource/grant CRUD (`basic.test.js`), P2P
168pairing + scope isolation (`org-pairing.test.js`), mirror mode
169(`mirror-mode.test.js`), restart persistence (`persistence.test.js`),
170resource bootstrap keys (`resource-keys.test.js`), and issue-composition
171(`org-issues-linked-core.test.js`).
48172
49The failing TDD spec is in `test/scope-isolation.test.js`:
173## Related
50174
51 - **Test A** (passing today) demonstrates the architectural bug:
52 any peer mounting the shared bootstrap key replicates ALL orgs.
53 - **Test B** (failing today) is the spec the refactor must satisfy:
54 `createOrgInvite(name, opts)` returns a bearer invite scoped to
55 one org; `joinOrg(invite)` mounts ONLY that org's autobase.
175- [`hyper-rooms`](../hyper-rooms) — the underlying `Room` (autobase + view + pairing) this builds on.
176- [`hyper-issues`](../hyper-issues) — sibling room schema; composed onto an org `Room` via `extraSchemas` so an org *is* its own issue tracker.
177- **`pear-git-v3`** — primary consumer (one `HyperOrgs` per identity for repo access control).
56178
57A runnable forward-looking demo lives at `examples/per-org-invite.js`
58— it prints "NOT IMPLEMENTED" today and runs end-to-end once the
59refactor lands.
179## Status
60180
61## Related
181The per-org-autobase refactor (issue #35) has **landed** — this is the
182current architecture, exercised by the P2P pairing and mirror-mode tests.
183Known follow-ups:
62184
63- `hyper-rooms` — the underlying Room class.
64- `pear-git-v3` — primary consumer.
65- `hyper-issues` — sibling room schema, same shape.
185- Mirror-mode HA **write** enrollment (making a sister daemon a writer on
186 every org) reuses `addWriter` and is not yet wired end-to-end.
187- The schema-level invite/access-request ops are defined but not exposed as
188 class methods (see Schema note above).
189- `examples/per-org-invite.js` predates the landed API and is stale (it
190 guards on `createOrgInvite` being absent and calls `joinOrg` without a
191 swarm); use `test/org-pairing.test.js` as the working reference.
docs/API.md +224 -0
……@@ -0,0 +1,224 @@
1# hyper-orgs API reference
2
3All methods are on the `HyperOrgs` class (`import { HyperOrgs } from 'hyper-orgs'`).
4Source of truth: `lib/hyper-orgs.js`. Async unless noted.
5
6## Construction & lifecycle
7
8### `new HyperOrgs(corestore, options?)`
9
10- `corestore` — a `Corestore`. Internally namespaced to `options.namespace`.
11- `options.namespace` — parent corestore namespace. Default `'orgs'`.
12- `options.extraSchemas` — array of additional `hyper-rooms` schemas composed
13 onto **every** org `Room`. The daemon injects `hyper-issues`' schema here so
14 an org `Room` doubles as its own issue tracker (org issues then replicate
15 *with* the org autobase). `hyper-orgs` never imports `hyper-issues` itself.
16
17### `await ready()`
18
19Opens the local `orgs-index` core and **replays it** to re-open every
20previously tracked org `Room` (created or joined). Idempotent. Must be
21awaited before any other call.
22
23### `await close()`
24
25Closes all live invite pairings, then every tracked org `Room`, and clears
26internal tracking.
27
28### `await sync()`
29
30`base.update()` on every tracked org `Room` (pull latest from replicas).
31
32### `replicate(conn)`
33
34Replicate every tracked org's autobase over a raw stream `conn`. Used by the
35daemon's swarm transport per connection — a multi-org instance has no single
36`base`, so this mirrors what `base.replicate` would do for each org.
37(`corestore.replicate` usually already covers these cores; this is
38belt-and-suspenders.) Not async.
39
40### `validateName(name)` → `{ valid, error? }`
41
42Delegates to the schema validator. Not async. See SCHEMA.md.
43
44## Per-org accessors
45
46### `get base`
47
48Convenience getter for **single-org** instances (e.g. a joiner):
49
50- 1 org tracked → that org's autobase
51- 0 orgs → `null`
52- \>1 org → **throws** (`use getOrgBase(name)`)
53
54### `getOrgBase(name)` → autobase | null
55
56That org's autobase, for replication. Not async.
57
58### `getOrgRoom(name)` → Room | null
59
60That org's `hyper-rooms` `Room` (exposes `.writable`, `.base`, `.view`,
61`.keyHex`, `.localKeyHex`). Not async.
62
63## Orgs
64
65### `await create(name, type?, data?)` → org record
66
67Mint a new org in its **own** fresh autobase.
68
69- `name` — validated (`validateName`); lowercase alnum + hyphens, 2–64 chars,
70 not a reserved name. Throws on invalid or already-tracked name.
71- `type` — `'group'` (default) or `'user'`. Other values throw.
72- `data` — `{ displayName?, description?, about?, userId?, members?, visibility? }`.
73 `userId` applies to `user` orgs; `members` to `group` orgs; `visibility`
74 defaults to `'public'`. `about` is free-form markdown (the org's
75 README-equivalent overview).
76
77The creating instance becomes the org's first autobase writer (role `admin`,
78`created: true`) and the org is recorded in the local index.
79
80### `await get(name)` → org record | null
81
82Reads `org:<name>` from that org's view after an `update()`. Returns `null`
83if not tracked or not found.
84
85### `await update(name, updates)` → org record
86
87Appends `update-org`. Mutable fields: `displayName`, `description`, `about`,
88`visibility`.
89
90### `await delete(name)`
91
92Appends `delete-org`, then drops the org from local tracking and the index.
93**Throws** if the org still has resources (remove them first). The autobase
94remains on disk; only local tracking is dropped.
95
96### `await list(filters?)` → org record[]
97
98Orgs this instance tracks. `filters.type` (`user`/`group`) and
99`filters.visibility` narrow the result.
100
101## Members (group orgs only)
102
103### `await addMember(orgName, userId, role?)` → org record
104
105`role` is `'member'` (default) or `'admin'`. Re-adding an existing member
106updates their role (upsert). Throws on a `user` org or invalid role.
107
108### `await removeMember(orgName, userId)` → org record
109
110### `await getUserOrgs(userId)` → org record[]
111
112Every tracked org where `userId` is the `user`-org owner or a `group` member.
113
114## Resources
115
116A **resource** is a name owned by exactly one org. `add-resource` steals the
117resource from any other org that currently holds it (single-owner invariant).
118
119### `await addResource(orgName, resourceName, opts?)` → org record
120
121`opts.bootstrapKey` records the resource's (repo tracker) bootstrap key in the
122org's `resourceKeys` map, so a peer that joins the org can mount exactly that
123repo (scoped). Re-adding with a key backfills a name-only entry. A no-op
124re-add (same name, no/identical key) returns early.
125
126### `await removeResource(orgName, resourceName)` → org record
127
128### `await moveResource(resourceName, fromOrg, toOrg)`
129
130Convenience: `removeResource(fromOrg)` then `addResource(toOrg)`. Both orgs
131must be tracked.
132
133## Grants & access
134
135Permission levels: `owner` (3) > `moderator` (2) > `write` (1). A grant lives
136in the autobase of the org it pertains to (a group grant in that org's `Room`;
137a resource grant in the `Room` of the org that owns the resource).
138
139### `static permissionLevel(perm)` → number
140
141`owner`→3, `moderator`→2, `write`→1, anything else →0.
142
143### `await createGrant(opts)` → grant record
144
145- `opts.subjectType` — `'user'` | `'server'`
146- `opts.subjectId` — subject key
147- `opts.grantType` — `'group'` (needs `groupName`) | `'resource'` (needs `resourceName`)
148- `opts.permission` — `'write'` | `'moderator'` | `'owner'`
149- `opts.grantId?` — defaults to `<subjectType>-<subjectId[:12]>-<ts>`
150- `opts.grantedBy?`, `opts.inviteId?`
151
152### `await revokeGrant(grantId, revokedBy?)`
153
154Marks the grant revoked (soft) in whichever tracked org's `Room` holds it.
155Throws if not found. (Revoked grants are retained, not deleted.)
156
157### `await getGrant(grantId)` → grant record | null
158
159### `await listGrants(filters?)` → grant record[]
160
161Filters: `subjectType`, `subjectId`, `grantType`, `groupName`,
162`resourceName`, and `activeOnly` (default `true` — set `false` to include
163revoked). The query narrows to one `Room` when `groupName` or a resolvable
164`resourceName` is given, else scans all tracked orgs.
165
166### `await checkAccess(opts)` → boolean
167
168Whether a subject has at least `opts.requiredPermission` on
169`opts.resourceName`. Resolves the resource to its owning org, then checks
170**direct resource grants** and **group grants** on that org, for whichever of
171`opts.userId` (→ user subject) and `opts.serverKey` (→ server subject) are
172supplied. Returns `false` if the resource isn't owned by any tracked org or
173`requiredPermission` resolves to level 0.
174
175## Invites & join (peer mode — blind-pairing over a swarm)
176
177All require an injected shared `Hyperswarm` (P2P transport; no HTTP).
178
179### `await createOrgInvite(name, opts)` → `{ invitation, descriptor, close }`
180
181Issue a blind-pairing invite for one org (hyper-rooms `createRoomPairing`).
182
183- `opts.swarm` — **required** shared Hyperswarm.
184- `opts.role` — pairing role, default `'reader'`. `contributor`+ joiners are
185 added as autobase writers during pairing; `reader` joiners stay read-only.
186
187The pairing member stays live (held internally) until `close()`. The org
188`name` travels in the invite `meta` so the joiner can key its record.
189`descriptor` lets the daemon persist and later `resumeOrgPairing`.
190
191### `await resumeOrgPairing(name, descriptor, swarm)` → pairing
192
193Re-register a persisted invite pairing after a restart (the org `Room` must
194already be mounted) so an already-shared invite keeps accepting joiners.
195
196### `await joinOrg(invitation, opts)` → `{ name, role }`
197
198Consume an org invite: mount **only** that org's autobase. `opts.swarm`
199required. Decodes the invite, rejects non-org invites, and for `contributor`+
200becomes a writer during pairing. Idempotent if already joined.
201
202## Mirror mode (full replica — no invite)
203
204### `await mountOrg(name, bootstrapKey, role?)` → `{ name, writerKey, role }`
205
206Mount an org's autobase directly by its bootstrap key. `role` default
207`'reader'`. Idempotent. The mirror counterpart to `joinOrg`.
208
209### `listOrgKeys()` → `[{ name, bootstrapKey, role, created }]`
210
211Every org this instance tracks, with its bootstrap key. A primary exposes
212this so a mirror can `mountOrg` each for a full replica. Peers never call this
213(they only receive specific invites) — which is what prevents cross-org
214leakage. Not async.
215
216## Writer management (delegated to the org's Room)
217
218Used for mirror-mode HA writer coordination. (Contributor write-enrollment
219during a normal join happens automatically inside `joinOrg`; the old
220HTTP `enrollWriter`/`enrollFromInvite` path is retired.)
221
222### `await addWriter(orgName, userId, options?)`
223### `await removeWriter(orgName, userId)`
224### `await listWriters(orgName)`
docs/SCHEMA.md +87 -0
……@@ -0,0 +1,87 @@
1# hyper-orgs schema reference
2
3`lib/schema.js` is the source of truth. The schema is a `hyper-rooms`
4`jsonSchema` with a `validate(op)` (rejects malformed ops) and an
5`apply(batch, op, host)` (writes the Hyperbee view). It is composed onto
6every org `Room`; writer-management ops (add/remove writer) are handled by
7`hyper-rooms` itself, not here.
8
9## Name validation
10
11`validateName(name)` / `RESERVED_NAMES`:
12
13- Required string, 2–64 chars.
14- Pattern `^[a-z0-9][a-z0-9-]*[a-z0-9]$` — lowercase alphanumeric and
15 hyphens, not starting or ending with a hyphen.
16- Reserved (rejected): `admin, api, git, settings, login, logout, register,
17 auth, static, assets, public, local, system, root, null, undefined`.
18
19## Operations
20
21Appended to an org `Room` (most via a `HyperOrgs` method; see notes).
22
23| Op | Required fields | Surfaced by |
24|----|-----------------|-------------|
25| `create-org` | `name`, `orgType` (`user`\|`group`) | `create()` |
26| `update-org` | `name` | `update()` |
27| `delete-org` | `name` | `delete()` |
28| `add-resource` | `orgName`, `resourceName` | `addResource()` |
29| `remove-resource` | `orgName`, `resourceName` | `removeResource()` |
30| `move-resource` | `resourceName`, `fromOrg`, `toOrg` | (`moveResource()` uses remove+add) |
31| `add-member` | `orgName`, `memberId`, `role` (`admin`\|`member`) | `addMember()` |
32| `remove-member` | `orgName`, `memberId` | `removeMember()` |
33| `create-grant` | `grantId`, `subjectType` (`user`\|`server`), `subjectId`, `grantType` (`group`\|`resource`), `permission` (`write`\|`moderator`\|`owner`) | `createGrant()` |
34| `revoke-grant` | `grantId` | `revokeGrant()` |
35| `create-invite` | `inviteId`, `inviteType` (`group`\|`resource`), `permission` | — *(latent)* |
36| `consume-invite` | `inviteId`, `grantId`, `subjectType`, `subjectId` | — *(latent)* |
37| `revoke-invite` | `inviteId` | — *(latent)* |
38| `create-access-request` | `requestId`, `subjectType`, `subjectId`, `requestType` (`group`\|`resource`), `permission` | — *(latent)* |
39| `approve-access-request` | `requestId`, `grantId` | — *(latent)* |
40| `deny-access-request` | `requestId` | — *(latent)* |
41| `set-meta` | `key` | — *(latent)* |
42
43For `grantType`/`inviteType`/`requestType` = `group`, a `groupName` is
44required; for `resource`, a `resourceName` (or `targetGroup`/`targetResource`
45for invites). See `validate()` for the exact per-type checks.
46
47**Latent ops** are defined and applied by the schema but have no `HyperOrgs`
48method yet. A consumer can append them directly to an org `Room`. They
49implement a full bearer-invite + access-request workflow (request → approve →
50auto-grant) that the class does not currently expose; the class uses
51blind-pairing invites instead.
52
53## View keys (Hyperbee)
54
55| Key | Value |
56|-----|-------|
57| `org:{name}` | `{ name, displayName, description, about, type, userId, members[], resources[], resourceKeys{}, visibility, createdAt, updatedAt }` |
58| `grant:{grantId}` | `{ grantId, subjectType, subjectId, grantType, groupName, resourceName, permission, grantedBy, grantedAt, revokedAt, revokedBy, inviteId }` |
59| `invite:{inviteId}` | `{ inviteId, inviteType, targetGroup, targetResource, permission, maxUses, usedCount, createdBy, createdAt, expiresAt, revoked }` |
60| `request:{requestId}` | `{ requestId, subjectType, subjectId, requestType, groupName, resourceName, permission, reason, status, createdAt, respondedAt, respondedBy, denyReason }` |
61| `user-grants:{subjectId}:{grantId}` | `{ grantId }` (secondary index) |
62| `server-grants:{subjectId}:{grantId}` | `{ grantId }` |
63| `group-grants:{groupName}:{grantId}` | `{ grantId }` |
64| `resource-grants:{resourceName}:{grantId}` | `{ grantId }` |
65| `user-requests:{subjectId}:{requestId}` | `{ requestId }` |
66| `server-requests:{subjectId}:{requestId}` | `{ requestId }` |
67| `group-requests:{groupName}:{requestId}` | `{ requestId }` |
68| `resource-requests:{resourceName}:{requestId}` | `{ requestId }` |
69| `meta:{key}` | `{ value }` |
70
71### Notes on org records
72
73- `resources` is a flat array of resource names; a resource belongs to
74 exactly one org (`add-resource` removes it from any other org).
75- `resourceKeys` maps `resourceName → bootstrapKey` (a grouped repo's tracker
76 key) for scoped mounting by peers. Legacy records without it read as `{}`.
77- `members` (group orgs) is `[{ userId, role, addedAt }]`; `add-member`
78 upserts by `userId`.
79- `about` is free-form markdown (org overview / README-equivalent). Legacy
80 records load with `about` undefined, treated as empty.
81
82### Idempotency / conflict behavior
83
84`apply` is defensive for autobase convergence: `create-org`/`create-grant`/
85`create-invite` no-op if the key already exists; `delete-org` refuses while
86resources remain; `revoke-grant`/`consume-invite`/access-request transitions
87no-op if already in the target state or limits are exceeded.