feat: adopt core 0.37 — permissions as associations, and the no-bump tier - #4
Merged
Merged
Conversation
…tier Record permissions are associations now, and six operations no longer move a version. Both reach the command surface, so this is one change rather than two. **The ACL is per-element.** `Permission[]` is replaced by two association kinds — `permission`, whose bit is its label and whose grantee carries a required role, and `anyone` for world-read spelled affirmatively. `perm` calls `grantAccess()`/`revokeAccess()` instead of reading the whole list, editing an entry and writing it back: that read-modify-write dropped whatever a second admin granted between the read and the write, which is exactly the case a sharing command has to survive. The flags follow the model rather than the old shape. `--public` becomes `--anyone`, which carries read alone, so `--write` beside it is refused here rather than written and bounced by core. `--group` requires `--role`, because member and admin name two different sets of people and neither is a safe default. `perm add --read --write` grants read first and `perm rm` withdraws write first — the one ordering that satisfies core's rule that no write lands in a set whose grantee cannot read it; the rule itself is left to core's message rather than re-derived. `perm ls` is new: the ACL was otherwise only legible in `show --json`. `grant`'s target is core's required grantee union — `--default` becomes `--authenticated`, a DID-holder tier distinct from `--anyone`'s anonymous reach, and `--group` takes `--role`. `grant ls` accepts the same flags as a query, widened by the listing-only `--role any`, and `grant rm` reports a count now that `revoke()` returns what it withdrew. **Associating, sharing, moving and (un)listing bump nothing.** `tag`, `link`, `perm` and `attach` stop printing a version that did not move and compare the record before and after instead, so a repeat says `already tagged` rather than claiming a write. `commit` reports the version its own `mutate()` produced; the tag and attachment reconcile that follow cannot move it. Naming `contentPatch` alongside `parentId` is what keeps a move under `ifVersion`, which is now pinned from both sides. Also: `rm --hard` reports the files the purged record referenced, since destroying the record destroys the only rows naming them, and `attach add` records an `attachmentRecordId` so a filename resolves to the upload that reference came from. Co-Authored-By: Claude Opus 5 <[email protected]>
cuibonobo
force-pushed
the
feat/core-0-37-permissions-as-associations
branch
from
September 23, 2026 20:30
d32a693 to
6fb20b3
Compare
`link`, `perm` and `grant` each spelled the other end differently — ten
flags between them, three sets of "exactly one of" rules, and three shapes
for what is, to the person typing, one idea. They now share `--to
<target>`, parsed once in src/target.ts and narrowed per command.
anyone the world, anonymous included (perm)
authenticated any entity holding a DID (grant)
did:key:z6Mk… an identity (all three)
group:<id>/<member|admin> a roster at one role (perm, grant)
record:<id>[@<stackUrl>] a record, here or elsewhere (link)
external:<ns>/<id> something outside any stack (link)
The vocabulary is the CLI's own and deliberately wider than any single
core union. Core keeps three apart on purpose — a RelationshipTarget names
no role, a PermissionGrantee has no record scope, and `anyone` is a kind
rather than a grantee, so no dropped field can produce world-read. That
split is right for the data model and wrong for a person, who is naming
Alice either way. So one grammar parses and each command narrows to the
arms it accepts, refusing the rest by name. The narrowing is where core's
distinctions are enforced; the grammar is where the convenience lives.
Three rules keep it honest. Exactly one shape is inferred — a leading
`did:` is an entity, because a DID is the one identifier every command
takes and `entity:did:key:…` reads badly on the most frequent call;
everything else names its scheme and an unknown one is an error, not a
fallback. `external:` nests its namespace rather than sharing the
top-level slot, since `ns` is open and user-chosen and letting it compete
with `group:`/`record:` would reserve words out of a namespace this CLI
does not own. And formatTarget() is parseTarget()'s inverse, so `perm ls`
and `grant ls` print targets unelided in the grammar their own commands
accept — a listing row is a command argument, which the smoke test pins by
feeding one back in.
`grant ls --role any` becomes `--to group:<id>/any`, which also moves the
listing widening and the world-read tier into structurally different slots
instead of leaving them one letter apart.
The single seam is the point: the deferred `--pick` selector resolves a
filter to an id and substitutes it into the invoking command, and against
three flag shapes that would have been three substitution paths.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013wPYq2Dp59HnoYsnzYbuYP
…parse Two changes to `--to`, both about keeping the shared grammar from blurring the distinctions core draws. **Every entry point returns one command's narrow type.** parsePermissionTarget(), parseGrantTarget(), parseGrantQuery() and parseLinkTarget() hand back core's own PermissionGrantee, GrantGrantee, GrantQuery and RelationshipTarget. The wide union is module-private and no longer exported, so no caller can hold an un-narrowed target and the per-command table is a type rather than a convention — parseLinkTarget() cannot return a grantee because RelationshipTarget has nowhere to put one. One shared parser still backs all four: one grammar to keep correct rather than four that drift. **A target from the wrong tier is told what to say instead**, never converted. `grant --to anyone` is pointed at `authenticated`, `link --to group:X/member` at `record:X` (a Group is a Record, so the redirect is exact), and `perm`/`grant --to record:X` at `group:X/<role>`. The asymmetry between the first two is deliberate and is the one place this could have gone wrong. `anyone` is the wider tier — it reaches anonymous requesters where `authenticated` reaches only DID holders — so `grant` suggesting `authenticated` narrows and is safe to offer, while `perm --to authenticated` must not be handed `anyone` back as a synonym. It names it and says which way it moves, leaving the widening a choice rather than an autocomplete. Offering it as an equivalent would be a quiet escalation, which is the same failure as converting between the tiers. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_013wPYq2Dp59HnoYsnzYbuYP
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the CLI up to core 0.37 / adapter 0.36 / commons 0.31 / server 0.10.3. Two
core changes reach the command surface, so they land together rather than as two
half-updates.
Record permissions are associations
Permission[]is replaced by two association kinds —permission, whose bit is itslabel and whose grantee carries a required
role, andanyone, which spellsworld-read affirmatively.
permcallsgrantAccess()/revokeAccess()instead ofreading the whole list, editing an entry and writing it back. That read-modify-write
is the thing worth dropping: between the read and the write it silently discarded
whatever a second admin granted, which is exactly the case a sharing command has to
survive.
The flags follow the model rather than the old shape:
perm … --publicperm … --anyonereadalone, so--writebeside it is refused here rather than written and bounced by coreperm … --group <id> [--role admin]perm … --group <id> --role <member|admin>perm ls <id>show --jsongrant … --defaultgrant … --authenticated--anyone's anonymous reach — the two never share a wordgrant ls [--type]grant ls [--type] [grantee flags]listGrants()takes a query now, widened by the listing-only--role anyperm add --read --writegrants read first andperm rmwithdraws write first — theone ordering that satisfies core's rule that no write lands in a set whose grantee
cannot read it. The rule itself is left to core's message rather than re-derived
client-side.
grant rmreports a count now thatrevoke()returns what it withdrew.Six operations no longer bump a version
associate,dissociate,permissions,reparent,unlistandlistleaveversionandupdatedAtwhere they stand. So:tag/link/perm/attachstop printing a version that did not move, andcompare the record before and after instead — a repeat now says
already taggedrather than claiming a write that did not happen.
commitreports the version its ownmutate()produced. The tag and attachmentreconcile that follow cannot move it, so the re-read it used to do was answering a
question nothing had changed.
contentPatchalongsideparentIdis what keeps a move underifVersion.Pinned from both sides: a move alone does not bump, and a move-plus-patch still
conflicts.
Also
rm --hardreports the files the purged record referenced — destroying the recorddestroys the only rows naming them, so GC can no longer see they were referenced.
attach addrecords anattachmentRecordId, so a filename resolves to the uploadthat reference came from rather than whichever record happened to upload the same
bytes.
Breaking
The SQLite schema is create-if-missing with no migrations, so a database written
before core 0.37 keeps its old tables and fails on the first permission write.
Existing stacks must be recreated. The changeset says so, and it is
minor—the breaking slot at
0.x.Verification
format:check,lint,test(188),typecheck,buildandsmokeall pass, onthis branch rebased onto current
main. The smoke script now drivesperm add|ls|rm --anyonethrough the built binary and asserts the no-bump property end to end.Dogfooded separately against a real seeded stack in the
cli-exampleplayground —every
permandgrantpath through the actual binary, tag/link/attach idempotence,rm --hard, and anew -c→edit -cloop that moves a record to a new parent andcorrectly reports an unchanged version. That turned up one thing the flags do not
show, now pinned in
exercise:relations:write implies readis an invariant overthe whole set, not one grantee, so a bare
writestands while ananyoneread isthere — and withdrawing that read underneath it is what gets refused.
🤖 Generated with Claude Code
https://claude.ai/code/session_013wPYq2Dp59HnoYsnzYbuYP