Repository navigation
feat: adopt core 0.36's journal, permission and versioning surfaces - #126
Merged
Merged
Conversation
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
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.
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.
Permissionis gone, replaced by two authority kinds —permission, whose bit is its label and whose grantee carries a requiredrole, andanyone, which spells reach to the world affirmatively. Record-level ACL changes get their own verbs beside the declarativepermissionskey:POST /records/:id/permissionsandPOST /records/:id/permissions/deleteamend 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 a400—ScopedStack's refusal, not a route-level guard.GET /records/:id/journalserves the second durable tier. The mutate-surface gate and the authority-element projection a non-resharer gets are bothScopedStack.getJournal()'s; the route decodes the window withparseJournalParams(), bounds the page at 500 and reportscursor, which is the only end-of-log signal. Omittinglimitreads the whole log by contract, so no default page size is supplied on the caller's behalf.A hard delete answers
200with the record it destroyed rather than204. 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 at404rather than the403the gate would raise.Associations,
permissions,parentIdandunlistedall bump no version, so the association endpoints stop readingIf-Match, and anIf-Matchbeside a change set naming only no-bump keys is not checked. A snapshot carries content and itstypeIdalone; 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:{ "access": "public" }shape, and now covers the two authority kinds, the grouprole, the grant/revoke endpoints and the reshare gate, the cross-element write implies read invariant, and the three type-level grantee tiersassociationsedits,seqvs. the feed's cursor, the page cap andcursor,previousParentId'snull, the gate and the authority projection, and the404-not-empty-log rulepermissions/associationspartition and its400, the correctedIf-Matchlist, what a snapshot carries, and the hard-delete bodyversionor snapshotsassociationsAdded/associationsRemovedon association frames; "every aspect the version moved" corrected to "the write moved"No new environment variables, so the README config table and
.env.exampleare untouched. The journal page cap is a module constant besideMAX_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:
The three new fixture blocks are dispatched —
grantAccessFixtures(2),revokeAccessFixtures(1),getJournalFixtures(5) — along with six new error fixtures (journal403/404, the two grantee validations, both wrong-surface refusals), two change-feed association frames,create-grant-record-group-grantee, andget-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.0and pass against0.35.1, which is why the range is pinned to that floor rather than the^0.35.0the bump would otherwise have taken.Notes for reviewers
This meets running deployments with no migration path.
applyRecordSchema()in core isCREATE TABLE IF NOT EXISTSwith noALTERs, so an existing database underDB_PATHkeeps itsrecords.permissionscolumn — 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. Thejournaltable is created cleanly on first open, so that half is fine. A migration belongs in core'ssqlite-sharedrather than here; worth filing there before anyone upgrades a real deployment.Two route-level judgment calls worth a look:
delete()runs its gate. That ordering is deliberate — it keeps an unreadable record at404— but it does mean a non-owner's refused purge costs one extra read.MAX_JOURNAL_LIMITis 500, picked to sit underMAX_QUERY_LIMITwithout a strong argument for the exact number. A page filled to the ceiling reports acursorwhether 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 (cursoris the only end-of-log signal), not an accident.🤖 Generated with Claude Code
https://claude.ai/code/session_014M4YU2MeHyRijQv4V32zQ3
Generated by Claude Code