Skip to content

Protect _config from ownership hijack, deletion, and query leakage (#67)#100

Merged
cuibonobo merged 1 commit into
mainfrom
claude/issue-67-design-review
Jul 16, 2026
Merged

Protect _config from ownership hijack, deletion, and query leakage (#67)#100
cuibonobo merged 1 commit into
mainfrom
claude/issue-67-design-review

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

The _config singleton — the stack's identity, read at open and consulted by every permission check — was unprotected:

  1. update('_config', { entityId: 'someone-else' }) passed schema validation cleanly and silently re-anchored stack ownership.
  2. delete('_config'), soft or hard, left the stack unreadable/unopenable.
  3. Query exclusion existed only as a hardcoded WHERE clause convention that a future adapter could easily forget.

Per the issue's layering principle — adapters are storage engines, Stack is the library's invariant layer, ScopedStack is per-requester policy — the fix for (1) and (2) belongs in Stack itself, inherited by every adapter and by ScopedStack (which delegates to Stack) without any adapter-specific code. Query exclusion is the deliberate exception: it has to stay a WHERE predicate to avoid post-filtering breaking pagination, and turns out to already be centralized in sqlite-shared for both real SQL adapters (landed as part of #46's refactor since this issue was filed).

Changes

  • Stack.update('_config', ...) rejects a changed entityId with StackConflictError (409) — a stack-integrity conflict, not a schema violation, since the new value is a perfectly valid string. Other fields (timezone) update normally.
  • Stack.delete('_config') is rejected unconditionally, soft or hard, with StackConflictError.
  • Stack.restoreVersion('_config', ...) rejects restoring a snapshot whose entityId disagrees with the live record's — closes the same door via the restore path.
  • MemoryAdapter (core's test double) now excludes _config from generic queries too, matching the real adapters' behavior and making the exclusion rule testable at the core level (previously it didn't model _config as a record at all).
  • docs/spec.md: _config protections documented under Stack initialization, the query-exclusion rule stated as normative, and the 409 error-table row extended to mention it.

Since ScopedStack.update()/delete()/restoreVersion() all delegate to the corresponding Stack method, these guards apply identically whether called directly or through a ScopedStack view — including for the stack owner.

Cross-referenced prerequisites (#49 identity, #53 error codes, #46 shared SQL layer, #50 MemoryAdapter fidelity, #52 fixtures, #55 ID validation, #62 restore validation) were already closed before this landed; #49 (DID identity) remains open but isn't a blocker — this issue only guards the field that exists today.

Test plan

  • packages/core typecheck clean
  • packages/core lint clean
  • Full packages/core test suite passing (430/430), including 11 new cases:
    • update() rejects an entityId change, allows a timezone change, and treats setting entityId to its current value as a no-op
    • soft delete and hard delete both rejected
    • restoreVersion() rejects a snapshot with a different entityId, allows one with the same
    • generic query excludes _config; get('_config') still works
    • ScopedStack-as-owner delegation: the owner can't bypass either guard through the scoped surface
  • record-adapter-sqlite (88/88) and sqlite-shared (36/36) suites unaffected — confirmed no other package manipulates _config through Stack (only via direct SQL/adapter-level test setup, which this change doesn't touch)

Generated by Claude Code

…y leakage (#67)

Nothing guarded the stack's identity record: update('_config', { entityId })
silently re-anchored ownership out from under every running permission
check, delete('_config') (soft or hard) left the stack unreadable or
unopenable, and query exclusion was a hardcoded WHERE clause one adapter
happened to remember. Per the issue's layering principle, these are
integrity guards that belong in Stack (inherited by every adapter and by
ScopedStack, which delegates) rather than adapter-specific conventions —
query exclusion is the one exception, since it has to stay a WHERE
predicate to avoid breaking pagination, and already lives in sqlite-shared
for both real SQL adapters.

- Stack.update('_config', ...) rejects a changed entityId (StackConflictError);
  other fields (timezone) update normally.
- Stack.delete('_config') is rejected unconditionally, soft or hard.
- Stack.restoreVersion('_config', ...) rejects restoring a snapshot whose
  entityId disagrees with the live record's.
- MemoryAdapter now excludes _config from generic queries too, matching the
  real adapters and making the exclusion rule testable/enforceable at the
  core level.

docs/spec.md: _config protections stated under Stack initialization, the
query-exclusion rule made normative, and the 409 error-table row extended.

Fixes #67

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDmUW3oNxPSCLf2ftqaF8q
@cuibonobo
cuibonobo merged commit bddcde8 into main Jul 16, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-67-design-review branch July 16, 2026 18:42
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