Skip to content

feat: adopt core 0.37 — permissions as associations, and the no-bump tier - #4

Merged
cuibonobo merged 3 commits into
mainfrom
feat/core-0-37-permissions-as-associations
Sep 24, 2026
Merged

cuibonobo merged 3 commits into
mainfrom
feat/core-0-37-permissions-as-associations

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

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 its
label and whose grantee carries a required role, and anyone, which spells
world-read affirmatively. perm calls grantAccess() / revokeAccess() instead of
reading 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:

Before Now Why
perm … --public perm … --anyone Carries read alone, so --write beside it is refused here rather than written and bounced by core
perm … --group <id> [--role admin] perm … --group <id> --role <member|admin> Member and admin name two different sets of people; neither is a safe default
— perm ls <id> The ACL was otherwise only legible in show --json
grant … --default grant … --authenticated A DID-holder tier, distinct from --anyone's anonymous reach — the two never share a word
grant ls [--type] grant ls [--type] [grantee flags] listGrants() takes a query now, widened by the listing-only --role any

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
client-side. grant rm reports a count now that revoke() returns what it withdrew.

Six operations no longer bump a version

associate, dissociate, permissions, reparent, unlist and list leave
version and updatedAt where they stand. So:

  • tag / link / perm / attach stop printing a version that did not move, and
    compare the record before and after instead — a repeat now says already tagged
    rather than claiming a write that did not happen.
  • commit reports the version its own mutate() produced. The tag and attachment
    reconcile that follow cannot move it, so the re-read it used to do was answering a
    question nothing had changed.
  • Naming contentPatch alongside parentId is what keeps a move under ifVersion.
    Pinned from both sides: a move alone does not bump, and a move-plus-patch still
    conflicts.

Also

  • rm --hard reports the files the purged record referenced — destroying the record
    destroys the only rows naming them, so GC can no longer see they were referenced.
  • attach add records an attachmentRecordId, so a filename resolves to the upload
    that 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, build and smoke all pass, on
this branch rebased onto current main. The smoke script now drives perm add|ls|rm --anyone through the built binary and asserts the no-bump property end to end.

Dogfooded separately against a real seeded stack in the cli-example playground —
every perm and grant path through the actual binary, tag/link/attach idempotence,
rm --hard, and a new -c → edit -c loop that moves a record to a new parent and
correctly reports an unchanged version. That turned up one thing the flags do not
show, now pinned in exercise:relations: write implies read is an invariant over
the whole set, not one grantee, so a bare write stands while an anyone read is
there — and withdrawing that read underneath it is what gets refused.

🤖 Generated with Claude Code

https://claude.ai/code/session_013wPYq2Dp59HnoYsnzYbuYP

…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
cuibonobo force-pushed the feat/core-0-37-permissions-as-associations branch from d32a693 to 6fb20b3 Compare September 23, 2026 20:30
cuibonobo and others added 2 commits September 23, 2026 17:26
`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
@cuibonobo
cuibonobo merged commit e366ae1 into main Sep 24, 2026
9 checks passed
@cuibonobo
cuibonobo deleted the feat/core-0-37-permissions-as-associations branch September 24, 2026 01:23
@github-actions github-actions Bot mentioned this pull request Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant