Skip to content

feat: adopt core 0.36's journal, permission and versioning surfaces - #126

Merged
cuibonobo merged 4 commits into
mainfrom
claude/server-repo-alignment-5ubew6
Sep 19, 2026
Merged

cuibonobo merged 4 commits into
mainfrom
claude/server-repo-alignment-5ubew6

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Four core releases moved the wire contract this server implements. The dependency bump (core 0.32 → 0.36, wire-types 0.35, adapter-local 0.35.1, commons 0.30, fixtures 0.29) is what forces the rest.

Permissions are associations now. Permission is gone, replaced by two authority kinds — permission, whose bit is its label and whose grantee carries a required role, and anyone, which spells reach to the world affirmatively. Record-level ACL changes get their own verbs beside the declarative permissions key: POST /records/:id/permissions and POST /records/:id/permissions/delete amend the set where the key replaces it, both carrying the reshare gate rather than the write bit. Authority and data share storage and never share a call, so a kind sent to the wrong surface is a 400 — ScopedStack's refusal, not a route-level guard.

GET /records/:id/journal serves the second durable tier. The mutate-surface gate and the authority-element projection a non-resharer gets are both ScopedStack.getJournal()'s; the route decodes the window with parseJournalParams(), bounds the page at 500 and reports cursor, which is the only end-of-log signal. Omitting limit reads the whole log by contract, so no default page size is supplied on the caller's behalf.

A hard delete answers 200 with the record it destroyed rather than 204. That body is the requester's only report of the files the purge stranded — every other row naming them is gone by the time it lands — so the record is read ahead of the write, which also keeps an unreadable one at 404 rather than the 403 the gate would raise.

Associations, permissions, parentId and unlisted all bump no version, so the association endpoints stop reading If-Match, and an If-Match beside a change set naming only no-bump keys is not checked. A snapshot carries content and its typeId alone; a restore leaves the record in whatever container it is in now.

Docs

The wire-contract half is haverstack/core#315 and the four releases before it — spec and fixtures live there. This PR carries this server's implementation and its own docs.

docs/api.md:

  • Permissions — rewritten; it still documented the old { "access": "public" } shape, and now covers the two authority kinds, the group role, the grant/revoke endpoints and the reshare gate, the cross-element write implies read invariant, and the three type-level grantee tiers
  • Journal — new section: the endpoint, the tagged associations edits, seq vs. the feed's cursor, the page cap and cursor, previousParentId's null, the gate and the authority projection, and the 404-not-empty-log rule
  • Records — the endpoint table (three new rows), the change-set example, a new paragraph on which keys bump, the permissions/associations partition and its 400, the corrected If-Match list, what a snapshot carries, and the hard-delete body
  • Unlisted — no longer claims the key bumps version or snapshots
  • Change feed — associationsAdded/associationsRemoved on association frames; "every aspect the version moved" corrected to "the write moved"

No new environment variables, so the README config table and .env.example are untouched. The journal page cap is a module constant beside MAX_QUERY_LIMIT, which is the existing precedent for a ceiling that is deployment policy rather than wire contract.

Verification

All four gates, from a clean install:

pnpm run format:check   All matched files use Prettier code style!
pnpm run lint           clean
pnpm typecheck          clean
pnpm test               Test Files  28 passed (28)
                        Tests      554 passed (554)

The three new fixture blocks are dispatched — grantAccessFixtures (2), revokeAccessFixtures (1), getJournalFixtures (5) — along with six new error fixtures (journal 403/404, the two grantee validations, both wrong-surface refusals), two change-feed association frames, create-grant-record-group-grantee, and get-versions-after-associate-is-unchanged. Every block's coverage gate passes; nothing is skipped beyond the two pre-existing client-obligation fixtures.

Checked by hand: the six journal fixtures fail against adapter-local@0.35.0 and pass against 0.35.1, which is why the range is pinned to that floor rather than the ^0.35.0 the bump would otherwise have taken.

Notes for reviewers

This meets running deployments with no migration path. applyRecordSchema() in core is CREATE TABLE IF NOT EXISTS with no ALTERs, so an existing database under DB_PATH keeps its records.permissions column — but nothing reads it any more, since permissions live in the associations table now. Every existing record's ACL is orphaned rather than migrated, which silently leaves those records owner-only. The journal table is created cleanly on first open, so that half is fine. A migration belongs in core's sqlite-shared rather than here; worth filing there before anyone upgrades a real deployment.

Two route-level judgment calls worth a look:

  • The hard-delete read happens before delete() runs its gate. That ordering is deliberate — it keeps an unreadable record at 404 — but it does mean a non-owner's refused purge costs one extra read.
  • MAX_JOURNAL_LIMIT is 500, picked to sit under MAX_QUERY_LIMIT without a strong argument for the exact number. A page filled to the ceiling reports a cursor whether or not the log ends there, so a client that reads exactly 500 entries makes one extra empty request. That's the spec's own shape (cursor is the only end-of-log signal), not an accident.

🤖 Generated with Claude Code

https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3


Generated by Claude Code

Four core releases moved the wire contract this server implements. The
dependency bump is what forces the rest.

Permissions are associations now. `Permission` is gone, replaced by two
authority kinds — `permission`, whose bit is its label and whose grantee
carries a required role, and `anyone`, which spells reach to the world
affirmatively. Record-level ACL changes get their own verbs beside the
declarative `permissions` key: POST /records/:id/permissions and
POST /records/:id/permissions/delete amend the set where the key replaces
it, both on the reshare gate rather than the write bit. Authority and data
share storage and never share a call, so a kind sent to the wrong surface
is a 400 — ScopedStack's refusal, not a route-level guard.

GET /records/:id/journal serves the second durable tier. The mutate-surface
gate and the authority-element projection a non-resharer gets are both
ScopedStack.getJournal()'s; the route decodes the window with
parseJournalParams(), bounds the page at 500 and reports `cursor`, which is
the only end-of-log signal. Omitting `limit` reads the whole log by
contract, so no default page size is supplied on the caller's behalf.

A hard delete answers 200 with the record it destroyed rather than 204.
That body is the requester's only report of the files the purge stranded —
every other row naming them is gone by the time it lands — so the record is
read ahead of the write, which also keeps an unreadable one at 404.

Associations, permissions, parentId and unlisted all bump no version now,
so the association endpoints stop reading If-Match and an If-Match beside a
change set naming only no-bump keys is not checked. A snapshot carries
content and its typeId alone, and a restore leaves the record in whatever
container it is in now.

Tests: the three new fixture blocks (grantAccess, revokeAccess, getJournal)
are dispatched, along with six new error fixtures, two change-feed
association frames, and the create/version fixtures core renamed. Every
block's coverage gate passes.

Requires the adapter-local journal fix from haverstack/core to be released:
LocalAdapter drops opts.journal on createRecord/associate/dissociate, so
the six journal fixtures fail against 0.35.0 and pass against the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3
…ntry

0.35.0 drops opts.journal on createRecord/associate/dissociate, so a stack
on it answers an empty log for a record whose history is not empty. The
journal endpoint cannot be served correctly below 0.35.1, which makes it
the floor rather than a range this happens to resolve past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3
Bumps core to 0.37 (wire-types 0.36, adapter-local 0.36, commons 0.31,
conformance-fixtures 0.30), which adds ScopedStack.deleteAndReturn():
the record is read and destroyed as one atomic adapter write instead
of a separate get() before delete(). The old ordering left a window
where a concurrent write could land between the read and the destroy,
get purged by it, and never appear in the response body naming what
the purge stranded. It also cost every hard delete an extra read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3UaSogfUhAqKscin9vFQD
parsePositiveInt accepts "0" (it checks digits, not positivity), so
limit=0 reached clampJournalLimit() unclamped and produced an empty
entries array with entries.length === limit true. The cursor
computation then indexed entries[-1], throwing a bare TypeError that
fell through to an unlabeled 500 instead of a clean empty page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3UaSogfUhAqKscin9vFQD
@cuibonobo
cuibonobo merged commit 24b9cb5 into main Sep 19, 2026
8 checks passed
@cuibonobo
cuibonobo deleted the claude/server-repo-alignment-5ubew6 branch September 19, 2026 20:38
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.

2 participants