Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5cc9c6a
test(calendar): define durable SQLite subscription contract
seonghobae Aug 16, 2026
f2bd18d
test(calendar): register SQLite persistence regression
seonghobae Aug 16, 2026
01ed37d
test(calendar): lock SQLite coverage registration
seonghobae Aug 16, 2026
7f66768
feat(calendar): add atomic SQLite subscription persistence
seonghobae Aug 16, 2026
f4a2e75
docs(calendar): trace durable SQLite subscription persistence
seonghobae Aug 16, 2026
6b03544
docs(calendar): record active SQLite persistence slice
seonghobae Aug 16, 2026
d725de7
test(calendar): cover SQLite fail-closed edge transitions
seonghobae Aug 16, 2026
6f04735
test(calendar): expose management revocation race
seonghobae Aug 16, 2026
517dcaf
test(calendar): run management race regression
seonghobae Aug 16, 2026
32860e2
fix(calendar): close management authorization races
seonghobae Aug 16, 2026
25162f0
test(calendar): expose rotation membership race
seonghobae Aug 16, 2026
09012da
fix(calendar): preserve rotation not-found boundary
seonghobae Aug 16, 2026
ccef905
docs(calendar): correct SQLite scenario evidence
seonghobae Aug 16, 2026
454d12e
test(calendar): expose savepoint cleanup masking
seonghobae Aug 16, 2026
eacc73b
test(calendar): expose rollback cleanup masking
seonghobae Aug 16, 2026
26b7346
fix(calendar): preserve causal savepoint errors
seonghobae Aug 16, 2026
ae5e6db
test(calendar): close SQLite integrity coverage gaps
seonghobae Aug 16, 2026
76fa467
merge: reconcile SQLite subscriptions with calendar parent
seonghobae Aug 16, 2026
6885434
test(calendar): cover issuance epoch persistence contract
seonghobae Aug 16, 2026
2ee8706
test(calendar): run issuance epoch SQLite regressions
seonghobae Aug 16, 2026
63e8898
fix(calendar): persist purpose and issuance epoch semantics
seonghobae Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Runtime route, database adapter/migration, and UI implementation remain
follow-up work under issue #413; the required calendar-management interaction
contract is captured in Figma and traced in the doctoring record.
- **Active PR #524; not yet protected-`develop` truth:** added normalized SQLite
persistence for calendar-subscription credentials with current-hash-only
storage, atomic live-membership checks, durable rotation/usage evidence,
idempotent revocation, a secret-free audit outbox, restart-survival tests, and
c8 registration. Protected route and customer UI migration remain follow-up
work under issue #413.

### Security

Expand Down
151 changes: 151 additions & 0 deletions docs/doctoring/calendar-subscription-sqlite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Calendar subscription SQLite persistence — active PR doctoring record

> **Status:** Active stacked PR work only. Nothing in this document is protected-`develop` shipped truth until the complete stack is independently reviewed, satisfies the live rulesets on the unchanged integrated head, and reaches protected `develop`.
>
> **Stack:** issue #413 → access-grant domain (#506) → calendar-subscription domain (#514) → SQLite persistence (#524). This slice deliberately does **not** change the protected calendar HTTP route, browser UI, deployment topology, or release version.

## Problem and bounded outcome

The protected calendar feed still depends on a broad session credential transported in a URL. The parent calendar-subscription domain (#514) defines a separately revocable, project-bound reusable credential lifecycle, but intentionally contains no production persistence. PR #524 supplies the durable SQLite adapter needed to make that lifecycle survivable across process restarts and enforce tenant/session revocation at the same atomic state transition that records a successful credential use.

The bounded buyer-visible value is operationally durable calendar access without storing a plaintext reusable subscription secret, while preserving immediate revocation/rotation semantics, cross-tenant nondisclosure, and immutable lifecycle evidence. Route migration and customer-facing management UI remain later slices and must not be represented as shipped by this PR.

## Current exact implementation boundary

`server/calendar_subscription_sqlite.mjs` owns four stable adapter surfaces:

- `installCalendarSubscriptionSchema(database)` installs normalized persistence relations and indexes at database bootstrap;
- `createSqliteCalendarSubscriptionRepository(database)` implements the parent domain repository port;
- `createSqliteCalendarSubscriptionAuthorizationPort(database)` verifies current project-organization membership for management actions without disclosing cross-tenant resource existence;
- `createSqliteCalendarSubscriptionMembershipPort(database)` returns an opaque live `membership_id:token_version` version used by the domain and repository to reject stale credentials.

No request handler invokes schema installation. SQLite foreign-key enforcement remains a connection/bootstrap responsibility because SQLite foreign-key enforcement is disabled by default unless enabled by the application, and changing `PRAGMA foreign_keys` within an active multi-statement transaction is ineffective. The existing server bootstrap therefore remains the correct ownership boundary for connection policy rather than this feature adapter.

## Data model and 3NF rationale

```mermaid
erDiagram
USERS ||--o{ CALENDAR_SUBSCRIPTIONS : subject
PROJECTS ||--o{ CALENDAR_SUBSCRIPTIONS : resource
CALENDAR_SUBSCRIPTIONS ||--o{ SUBSCRIPTION_ROTATIONS : history
CALENDAR_SUBSCRIPTIONS ||--o{ SUBSCRIPTION_USAGE_EVENTS : history

CALENDAR_SUBSCRIPTIONS {
text subscription_id PK
text secret_hash UK
integer subject_id FK
integer project_id FK
text name
text audience
text membership_version
integer created_at_ms
integer expires_at_ms
integer last_used_at_ms
integer rotated_at_ms
integer revoked_at_ms
}

SUBSCRIPTION_ROTATIONS {
integer rotation_event_id PK
text subscription_id FK
integer rotated_at_ms
integer expires_at_ms
}

SUBSCRIPTION_USAGE_EVENTS {
integer usage_event_id PK
text subscription_id FK
integer used_at_ms
}

CALENDAR_SUBSCRIPTION_AUDIT_OUTBOX {
integer audit_event_id PK
text subscription_id
text event_type
integer subject_id
integer project_id
integer occurred_at_ms
integer delivered_at_ms
}
```

`calendar_subscriptions` is the current authorization relation. It contains one current hash and current lifecycle state only. `subscription_rotations` and `subscription_usage_events` contain independent repeating event facts, preventing repeating groups or history arrays in the authorization row. The audit outbox is an immutable security-event ledger/delivery relation rather than a copy of current authorization state: `subject_id` and `project_id` are intentionally event attributes captured at occurrence time so retained evidence does not depend on a subsequently deleted authorization row. Its lack of a foreign key to `calendar_subscriptions` is deliberate for security-event retention and retryability after resource deletion.

All owned table/index names contain multiple lexical words and use snake_case. The focused schema test also executes `PRAGMA foreign_key_check` and locks exact owned-object names to prevent silent naming/normalization drift.

## Credential and tenant-security invariants

1. The one-time plaintext credential exists only at the parent domain `create()`/`rotate()` return boundary. The SQLite adapter receives and stores only SHA-256 hashes.
2. Only the current hash remains in `calendar_subscriptions`; historical rotation, usage, and audit relations contain no secret or hash fields. Rotation therefore cannot create a credential-hash archive.
3. Calendar audience is fixed to `scopeweave:calendar`; authorization additionally binds the credential to exactly one project.
4. Create rechecks the domain-captured membership version inside the SQLite savepoint before inserting state.
5. Use performs a conditional update that simultaneously verifies current hash, project, audience, non-revocation, pre-expiry time, stored membership-version snapshot, and independently resolved live membership/session version before it records `last_used_at_ms` and usage evidence.
6. Removing and re-adding an organization membership changes the membership-row identity. Session-wide invalidation changes `users.token_version`. Either change makes an already issued credential unusable until an authenticated operator explicitly rotates it.
7. Rotation rechecks current management authorization and live membership, replaces the sole current hash, and snapshots the fresh membership version in one savepoint. The previous secret is immediately invalid and no prior hash is retained.
8. Revocation is operator-idempotent: repeated authenticated revoke requests return the already-revoked state without duplicating the durable revocation event.
9. Cross-tenant management is nondisclosing: unknown and inaccessible project management requests fail through the same parent-domain not-found boundary.

This credential is ScopeWeave-specific and must **not** be represented as an OAuth access token. RFC 9700 is used as current threat/least-privilege evidence—particularly its guidance to reduce bearer-token exposure and applicability—not as a claim of protocol conformance.

## Transaction and evidence design

Repository transitions use named SQLite `SAVEPOINT` / `ROLLBACK TO` / `RELEASE` boundaries rather than unconditional `BEGIN`/`COMMIT`. SQLite documents that savepoints may be nested within an existing transaction and that `ROLLBACK TO` rewinds state without cancelling the outer transaction. This lets the adapter compose safely with a future wider request/outbox transaction instead of failing because nested `BEGIN` transactions are unsupported.

For create, successful use, rotate, and first revoke, lifecycle state and `calendar_subscription_audit_outbox` evidence are written inside the same savepoint. A test-installed trigger forces an outbox write failure during use and verifies that `last_used_at_ms` and `subscription_usage_events` both roll back. This is the executable failure-mode evidence for the “state and durable evidence together” contract rather than an assertion-only transaction test.

Outbox delivery itself is intentionally outside this slice. A later worker may mark `delivered_at_ms`; deterministic authorization never depends on model judgement or outbox-delivery availability.

## TDD and acceptance evidence

The initial test-only head imported the absent `server/calendar_subscription_sqlite.mjs`. The hosted Server Tests run failed with `ERR_MODULE_NOT_FOUND`, demonstrating that production implementation was required before the persistence contract could pass. After the adapter was added, all ten focused SQLite behavior scenarios passed together with the repository unit/API suite and cloud browser E2E in the observed hosted run.

However, those Server Tests currently check out GitHub's synthetic `refs/pull/524/merge` SHA rather than the contributor head. The observed GREEN checkout was synthetic merge `086f0e972e858264eae0dbd88091b476d9547cda`, produced from contributor head `7f667689b237e0910d99f47bfce63e6a267d2a85` over parent `cf12559739cc3161000e6e6dedfe9370033acb7a`. Under ScopeWeave's quality contract, synthetic/predecessor evidence is explicitly non-passing. PR #523/#522 addresses that workflow defect; #524 cannot promote this run to exact-head merge evidence.

Focused acceptance coverage includes:

- hash-only durable create and safe list metadata;
- repeat authorization for the correct project before expiry and exact-expiry rejection;
- token-version revocation and membership remove/re-add invalidation;
- authenticated rotation after session-version change while the old secret remains invalid;
- absence of secrets/hashes from rotation, usage, and audit history relations;
- idempotent revocation with one durable revoke event;
- cross-tenant management nondisclosure;
- file-backed reopen/process-survival behavior;
- transactional rollback when durable audit evidence cannot be written;
- schema naming, normalized history relations, and foreign-key integrity.

The canonical c8 command includes `server/calendar_subscription_sqlite.mjs` and the focused persistence test. Exact 100% statement/branch/function/line evidence remains mandatory before this PR can be considered integration-ready; a normal unit-test GREEN run does not substitute for that measurement.

## Traceability

| Requirement / risk | Executable evidence | Implementation boundary | Status |
| --- | --- | --- | --- |
| Reusable calendar secret is never plaintext at rest | hash-only persistence/list assertions | `calendar_subscriptions.secret_hash` | Active PR |
| Old secret is unusable after rotation | old/new authorization regression | `rotateSubscriptionAtomically` | Active PR |
| Logout-all/session revocation invalidates subscription | `token_version` mutation regression | membership version + atomic use SQL | Active PR |
| Membership removal/re-add does not revive old credential | membership-row replacement regression | opaque `membership_id:token_version` | Active PR |
| Cross-tenant project existence is not disclosed | other-tenant list/rotate regression | authorization port + parent domain mapping | Active PR |
| State and audit evidence cannot diverge on write failure | forced-outbox-failure rollback regression | savepoint transaction | Active PR |
| Durable credential survives process restart | file-backed SQLite reopen regression | SQLite repository | Active PR |
| History contains no credential material | schema introspection assertions | rotation/usage/audit relations | Active PR |
| DB object naming and referential integrity | exact object list + `foreign_key_check` | schema installer | Active PR |
| Exact-head CI evidence | must execute contributor SHA, not PR merge ref | repository workflow ownership | Blocked on #523/#522 integration |
| Independent current-head approval | qualifying independent reviewer after latest push | protected branch/ruleset governance | External governance prerequisite |
| Calendar route no longer consumes broad session JWT | future API migration | `server/app.mjs` | Planned / out of this slice |
| Customer can create/copy/rotate/revoke subscription in UI | future implementation matching Figma contract from #514 | browser client | Planned / out of this slice |

## Rollback and recovery

Before route integration, rollback of this slice consists of removing the SQLite adapter, focused tests, coverage registrations, doctoring entry, and changelog line together. Because no protected route or protected schema migration consumes these relations yet, that rollback creates no production credential downtime.

After a future route migration, rollback must never restore the broad session-JWT query credential as a “safe” steady state. Durable revocation/rotation/audit history must be retained according to the future retention policy, and credential material must not be reconstructed from logs or history.

## References

Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700; BCP 240). RFC Editor. https://doi.org/10.17487/RFC9700

SQLite. (n.d.). *Savepoints*. Retrieved August 16, 2026, from https://sqlite.org/lang_savepoint.html

SQLite. (2026, February 18). *Transaction*. https://sqlite.org/lang_transaction.html

SQLite. (n.d.). *SQLite foreign key support*. Retrieved August 16, 2026, from https://www.sqlite.org/foreignkeys.html
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs",
"test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/coverage-script-contract.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/coverage-script-contract.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/access_grant_domain.mjs --include=server/calendar_subscription_domain.mjs --include=server/calendar_subscription_sqlite.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/access-grant-domain.test.mjs && node tests/unit/access-grant-domain-edge.test.mjs && node tests/unit/calendar-subscription-domain.test.mjs && node tests/unit/calendar-subscription-domain-edge.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js",
Expand Down
Loading
Loading