From 930d7721b8054fa334d1d0d8e24bfcf9f1d4064c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:45:34 +0900 Subject: [PATCH 01/33] test(calendar): define durable SQLite subscription contract --- .../calendar-subscription-sqlite.test.mjs | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/unit/calendar-subscription-sqlite.test.mjs diff --git a/tests/unit/calendar-subscription-sqlite.test.mjs b/tests/unit/calendar-subscription-sqlite.test.mjs new file mode 100644 index 00000000..71119cf0 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite.test.mjs @@ -0,0 +1,361 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + createCalendarSubscriptionService, +} from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installCoreSchema(db) { + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + `); +} + +function seed(db) { + db.exec(` + INSERT INTO users(id, token_version) VALUES (1, 0), (2, 0); + INSERT INTO orgs(id) VALUES (10), (20); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1), (200, 20, 2); + INSERT INTO projects(id, org_id) VALUES (1000, 10), (2000, 20); + `); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +function mutableClock(initial = 1_000_000) { + return { + value: initial, + nowMs() { + return this.value; + }, + }; +} + +function serviceFor(db, clock = mutableClock()) { + installCalendarSubscriptionSchema(db); + const auditEvents = []; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(db), + clock, + randomSource: deterministicRandomSource(), + auditSink: { record: async (event) => auditEvents.push(event) }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(db), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(db), + }); + return { service, auditEvents, clock }; +} + +const createRequest = (overrides = {}) => ({ + subjectId: '1', + projectId: '1000', + name: 'Primary calendar', + expiresAtMs: 2_000_000, + ...overrides, +}); + +function secretHash(secret) { + return createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +test('SQLite calendar adapter persists hash-only reusable state and safe list metadata', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + + const created = await service.create(createRequest()); + assert.equal(created.secret.length, 43); + const stored = db.prepare('SELECT * FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.ok(stored); + assert.equal(stored.subject_id, 1); + assert.equal(stored.project_id, 1000); + assert.equal(stored.membership_version, '100:0'); + assert.equal(stored.secret_hash, secretHash(created.secret)); + assert.notEqual(stored.secret_hash, created.secret); + assert.equal(Object.hasOwn(stored, 'secret'), false); + + const listed = await service.list({ subjectId: '1', projectId: '1000' }); + assert.equal(listed.length, 1); + assert.equal(listed[0].subscriptionId, created.subscriptionId); + assert.equal(listed[0].status, 'active'); + assert.equal(Object.hasOwn(listed[0], 'secret'), false); + assert.equal(Object.hasOwn(listed[0], 'secret_hash'), false); + assert.equal(Object.hasOwn(listed[0], 'membership_version'), false); + + const outbox = db.prepare('SELECT * FROM calendar_subscription_audit_outbox ORDER BY audit_event_id').all(); + assert.deepEqual(outbox.map(({ event_type }) => event_type), ['created']); + assert.ok(outbox.every((row) => !JSON.stringify(row).includes(created.secret))); + db.close(); +}); + +test('calendar secret is reusable only for its project, live membership, and pre-expiry window', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '2000' }), + (error) => error?.code === 'calendar_subscription_unauthorized' && error?.status === 401, + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 0); + + const first = await service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(first.subscriptionId, created.subscriptionId); + clock.value += 1_000; + await service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 2); + assert.equal( + db.prepare('SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId).last_used_at_ms, + clock.value, + ); + + clock.value = 2_000_000; + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'exact expiry is not usable', + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 2); + db.close(); +}); + +test('membership revocation invalidates use while authenticated rotation snapshots the new version', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = 1').run(); + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'session-version change makes the stored subscription unusable', + ); + + clock.value += 100; + const rotated = await service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_100_000, + }); + assert.notEqual(rotated.secret, created.secret); + assert.equal( + db.prepare('SELECT membership_version FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId).membership_version, + '100:1', + 'rotation snapshots the independently rechecked live membership version', + ); + await assert.rejects(service.authorize({ secret: created.secret, projectId: '1000' })); + await service.authorize({ secret: rotated.secret, projectId: '1000' }); + + db.prepare('DELETE FROM memberships WHERE id = 100').run(); + db.prepare('INSERT INTO memberships(id, org_id, user_id) VALUES (101, 10, 1)').run(); + await assert.rejects( + service.authorize({ secret: rotated.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + 'membership remove/re-add changes the durable authorization version', + ); + db.close(); +}); + +test('rotation invalidates the old hash without retaining credential material in history relations', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + const oldHash = secretHash(created.secret); + + clock.value += 500; + const rotated = await service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }); + const current = db.prepare('SELECT secret_hash, rotated_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.equal(current.secret_hash, secretHash(rotated.secret)); + assert.notEqual(current.secret_hash, oldHash); + assert.equal(current.rotated_at_ms, clock.value); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscriptions WHERE secret_hash = ?').get(oldHash).count, 0); + + const rotationColumns = db.prepare('PRAGMA table_info(subscription_rotations)').all().map(({ name }) => name); + const usageColumns = db.prepare('PRAGMA table_info(subscription_usage_events)').all().map(({ name }) => name); + const outboxColumns = db.prepare('PRAGMA table_info(calendar_subscription_audit_outbox)').all().map(({ name }) => name); + for (const columns of [rotationColumns, usageColumns, outboxColumns]) { + assert.equal(columns.some((name) => /secret|hash/i.test(name)), false, 'history/audit relations retain no credential material'); + } + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, 1); + db.close(); +}); + +test('revocation is idempotent, auditable once, and blocks subsequent use', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const clock = mutableClock(); + const { service } = serviceFor(db, clock); + const created = await service.create(createRequest()); + + clock.value += 700; + const first = await service.revoke({ subjectId: '1', projectId: '1000', subscriptionId: created.subscriptionId }); + const second = await service.revoke({ subjectId: '1', projectId: '1000', subscriptionId: created.subscriptionId }); + assert.equal(first.status, 'revoked'); + assert.equal(second.revokedAtMs, first.revokedAtMs); + assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'").get().count, + 1, + ); + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_unauthorized', + ); + db.close(); +}); + +test('tenant management is nondisclosing and cannot list or mutate another organization subscription', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + const created = await service.create(createRequest()); + + await assert.rejects( + service.list({ subjectId: '2', projectId: '1000' }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + ); + await assert.rejects( + service.rotate({ + subjectId: '2', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }), + (error) => error?.code === 'calendar_subscription_not_found', + ); + db.close(); +}); + +test('state survives process-style reopen and remains usable until explicit revocation', async () => { + const dir = mkdtempSync(join(tmpdir(), 'scopeweave-calendar-subscription-')); + const file = join(dir, 'calendar.sqlite'); + try { + let db = new DatabaseSync(file); + installCoreSchema(db); + seed(db); + const first = serviceFor(db); + const created = await first.service.create(createRequest()); + db.close(); + + db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys = ON'); + const second = serviceFor(db); + const authorized = await second.service.authorize({ secret: created.secret, projectId: '1000' }); + assert.equal(authorized.subscriptionId, created.subscriptionId); + db.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('repository rolls back usage state and immutable evidence together on audit-outbox failure', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + const { service } = serviceFor(db); + const created = await service.create(createRequest()); + db.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + await assert.rejects(service.authorize({ secret: created.secret, projectId: '1000' }), /forced audit outbox failure/); + const row = db.prepare('SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?').get(created.subscriptionId); + assert.equal(row.last_used_at_ms, null); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, 0); + db.close(); +}); + +test('owned schema is normalized, uses descriptive multiword names, and passes foreign-key integrity', async () => { + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + installCalendarSubscriptionSchema(db); + + const owned = db.prepare(` + SELECT name, type + FROM sqlite_master + WHERE name LIKE 'calendar_subscription%' + OR name LIKE 'subscription_rotation%' + OR name LIKE 'subscription_usage%' + ORDER BY name + `).all(); + assert.deepEqual( + owned.map(({ name }) => name), + [ + 'calendar_subscription_audit_delivery_index', + 'calendar_subscription_audit_outbox', + 'calendar_subscription_secret_hash_index', + 'calendar_subscription_subject_project_index', + 'calendar_subscriptions', + 'subscription_rotation_history_index', + 'subscription_rotations', + 'subscription_usage_events', + 'subscription_usage_history_index', + ], + ); + assert.ok(owned.every(({ name }) => name.includes('_')), 'every owned SQLite object uses multiple lexical words'); + assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []); + + const rotations = db.prepare('PRAGMA table_info(subscription_rotations)').all().map(({ name }) => name); + assert.deepEqual(rotations, ['rotation_event_id', 'subscription_id', 'rotated_at_ms', 'expires_at_ms']); + const usage = db.prepare('PRAGMA table_info(subscription_usage_events)').all().map(({ name }) => name); + assert.deepEqual(usage, ['usage_event_id', 'subscription_id', 'used_at_ms']); + db.close(); +}); From f79c86d78746a529251d70c7edcaccc6e2000673 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:46:10 +0900 Subject: [PATCH 02/33] test(calendar): register SQLite persistence regression --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index bb3cb416..10df7c15 100644 --- a/package.json +++ b/package.json @@ -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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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/calendar-subscription-return-boundary.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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.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 tests/e2e/toast-accessibility.spec.js", From 0d9c01afd1a4e90aa5c7bba2dee2f978ca010fa4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:46:33 +0900 Subject: [PATCH 03/33] test(calendar): lock SQLite coverage registration --- tests/unit/coverage-script-contract.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 46ab903e..220263f2 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -44,6 +44,11 @@ assert.match( /--include=server\/calendar_subscription_domain\.mjs/, 'the durable calendar-subscription domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/calendar_subscription_sqlite\.mjs/, + 'the durable calendar-subscription SQLite adapter is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/access-grant-domain\.test\.mjs/, @@ -69,6 +74,11 @@ assert.match( /tests\/unit\/calendar-subscription-return-boundary\.test\.mjs/, 'the calendar-subscription atomic-return trust-boundary regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-sqlite\.test\.mjs/, + 'the calendar-subscription SQLite persistence contract executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, From 486b26a8e06bb7aeb54d9f8bdd37d920f153f46e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:48:49 +0900 Subject: [PATCH 04/33] feat(calendar): add atomic SQLite subscription persistence --- server/calendar_subscription_sqlite.mjs | 433 ++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 server/calendar_subscription_sqlite.mjs diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs new file mode 100644 index 00000000..2b9159c4 --- /dev/null +++ b/server/calendar_subscription_sqlite.mjs @@ -0,0 +1,433 @@ +const INSERT_SAVEPOINT = 'calendar_subscription_insert_state'; +const USAGE_SAVEPOINT = 'calendar_subscription_usage_state'; +const ROTATE_SAVEPOINT = 'calendar_subscription_rotate_state'; +const REVOKE_SAVEPOINT = 'calendar_subscription_revoke_state'; +const CALENDAR_AUDIENCE = 'scopeweave:calendar'; + +function requireDatabase(database) { + if (!database || typeof database.exec !== 'function' || typeof database.prepare !== 'function') { + throw new TypeError('calendar-subscription SQLite adapter requires a database with exec() and prepare()'); + } + return database; +} + +function normalizeSubscriptionRow(row) { + if (!row) return null; + return { + ...row, + subject_id: String(row.subject_id), + project_id: String(row.project_id), + }; +} + +function withSavepoint(database, savepointName, operation) { + database.exec(`SAVEPOINT ${savepointName}`); + try { + const result = operation(); + database.exec(`RELEASE ${savepointName}`); + return result; + } catch (error) { + database.exec(`ROLLBACK TO ${savepointName}`); + database.exec(`RELEASE ${savepointName}`); + throw error; + } +} + +function membershipVersionStatement(database) { + return database.prepare(` + SELECT CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT) AS membership_version + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = ? AND m.user_id = ? + `); +} + +function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { + const live = statement.get(projectId, subjectId); + if (!live?.membership_version || String(live.membership_version) !== String(expectedVersion)) { + throw new Error('calendar_subscription_membership_inactive'); + } + return String(live.membership_version); +} + +/** + * Install normalized durable storage for reusable calendar subscriptions. + * + * The authorization relation stores only the currently active SHA-256 secret + * hash. Rotation and usage relations contain lifecycle facts only and never + * retain either plaintext credentials or historical hashes. The audit outbox + * intentionally has no foreign key to the live subscription so security-event + * evidence survives resource deletion; delivery can therefore be retried by a + * later operator without restoring authorization state. + * + * Call this during database bootstrap with foreign-key enforcement enabled. + * Request handlers must never perform schema installation. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {void} + */ +export function installCalendarSubscriptionSchema(database) { + const db = requireDatabase(database); + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_subscriptions ( + subscription_id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL UNIQUE CHECK(length(secret_hash) = 64), + subject_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 120), + audience TEXT NOT NULL CHECK(audience = '${CALENDAR_AUDIENCE}'), + membership_version TEXT NOT NULL CHECK(length(membership_version) BETWEEN 1 AND 128), + created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), + expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms > created_at_ms), + last_used_at_ms INTEGER, + rotated_at_ms INTEGER, + revoked_at_ms INTEGER, + CHECK(last_used_at_ms IS NULL OR last_used_at_ms >= created_at_ms), + CHECK(rotated_at_ms IS NULL OR rotated_at_ms >= created_at_ms), + CHECK(revoked_at_ms IS NULL OR revoked_at_ms >= created_at_ms) + ); + CREATE INDEX IF NOT EXISTS calendar_subscription_secret_hash_index + ON calendar_subscriptions(secret_hash); + CREATE INDEX IF NOT EXISTS calendar_subscription_subject_project_index + ON calendar_subscriptions(subject_id, project_id, revoked_at_ms, expires_at_ms); + + CREATE TABLE IF NOT EXISTS subscription_rotations ( + rotation_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES calendar_subscriptions(subscription_id) ON DELETE CASCADE, + rotated_at_ms INTEGER NOT NULL CHECK(rotated_at_ms >= 0), + expires_at_ms INTEGER NOT NULL CHECK(expires_at_ms > rotated_at_ms) + ); + CREATE INDEX IF NOT EXISTS subscription_rotation_history_index + ON subscription_rotations(subscription_id, rotated_at_ms); + + CREATE TABLE IF NOT EXISTS subscription_usage_events ( + usage_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES calendar_subscriptions(subscription_id) ON DELETE CASCADE, + used_at_ms INTEGER NOT NULL CHECK(used_at_ms >= 0) + ); + CREATE INDEX IF NOT EXISTS subscription_usage_history_index + ON subscription_usage_events(subscription_id, used_at_ms); + + CREATE TABLE IF NOT EXISTS calendar_subscription_audit_outbox ( + audit_event_id INTEGER PRIMARY KEY, + subscription_id TEXT NOT NULL, + event_type TEXT NOT NULL CHECK(event_type IN ('created', 'used', 'rotated', 'revoked')), + subject_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + occurred_at_ms INTEGER NOT NULL CHECK(occurred_at_ms >= 0), + delivered_at_ms INTEGER + ); + CREATE INDEX IF NOT EXISTS calendar_subscription_audit_delivery_index + ON calendar_subscription_audit_outbox(delivered_at_ms, audit_event_id); + `); +} + +/** + * Create the SQLite implementation of the CalendarSubscriptionRepository port. + * + * Every security transition uses a savepoint so it composes with a wider caller + * transaction while still rolling back lifecycle state and durable audit-outbox + * evidence together. Create and rotate compare the membership version supplied + * by the domain with live database membership inside that transaction. Usage + * additionally requires the stored snapshot to match the live version in the + * same conditional UPDATE, closing membership-removal and session-revocation + * races. Rotation replaces the sole current hash; no historical credential hash + * is copied into history relations. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{insertSubscription: Function, listSubscriptions: Function, findSubscriptionByHash: Function, recordUsageAtomically: Function, rotateSubscriptionAtomically: Function, revokeSubscriptionAtomically: Function}} Repository adapter. + */ +export function createSqliteCalendarSubscriptionRepository(database) { + const db = requireDatabase(database); + const liveMembershipVersion = membershipVersionStatement(db); + const insertSubscription = db.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, audience, + membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + `); + const listSubscriptions = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subject_id = ? AND project_id = ? + ORDER BY created_at_ms DESC, subscription_id ASC + `); + const findByHash = db.prepare('SELECT * FROM calendar_subscriptions WHERE secret_hash = ?'); + const findScopedById = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subscription_id = ? AND subject_id = ? AND project_id = ? + `); + const recordUsage = db.prepare(` + UPDATE calendar_subscriptions + SET last_used_at_ms = CASE + WHEN last_used_at_ms IS NULL OR last_used_at_ms < ? THEN ? + ELSE last_used_at_ms + END + WHERE secret_hash = ? + AND project_id = ? + AND audience = ? + AND revoked_at_ms IS NULL + AND ? >= created_at_ms + AND ? < expires_at_ms + AND membership_version = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + AND (CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT)) = ? + ) + `); + const replaceSecret = db.prepare(` + UPDATE calendar_subscriptions + SET secret_hash = ?, + membership_version = ?, + expires_at_ms = ?, + rotated_at_ms = ? + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND revoked_at_ms IS NULL + AND ? >= created_at_ms + AND ? > ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + JOIN users u ON u.id = m.user_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + AND (CAST(m.id AS TEXT) || ':' || CAST(u.token_version AS TEXT)) = ? + ) + `); + const revokeSubscription = db.prepare(` + UPDATE calendar_subscriptions + SET revoked_at_ms = ? + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND revoked_at_ms IS NULL + `); + const insertRotation = db.prepare(` + INSERT INTO subscription_rotations(subscription_id, rotated_at_ms, expires_at_ms) + VALUES(?,?,?) + `); + const insertUsage = db.prepare(` + INSERT INTO subscription_usage_events(subscription_id, used_at_ms) + VALUES(?,?) + `); + const insertAudit = db.prepare(` + INSERT INTO calendar_subscription_audit_outbox( + subscription_id, event_type, subject_id, project_id, occurred_at_ms, delivered_at_ms + ) VALUES(?,?,?,?,?,NULL) + `); + + return Object.freeze({ + /** + * Persist one hash-only subscription after atomically rechecking the live + * membership version captured by the domain. + */ + async insertSubscription(record) { + withSavepoint(db, INSERT_SAVEPOINT, () => { + const membershipVersion = assertLiveMembershipVersion( + liveMembershipVersion, + record.project_id, + record.subject_id, + record.membership_version, + ); + insertSubscription.run( + record.subscription_id, + record.secret_hash, + record.subject_id, + record.project_id, + record.name, + record.audience, + membershipVersion, + record.created_at_ms, + record.expires_at_ms, + record.last_used_at_ms, + record.rotated_at_ms, + record.revoked_at_ms, + ); + insertAudit.run( + record.subscription_id, + 'created', + record.subject_id, + record.project_id, + record.created_at_ms, + ); + }); + }, + + /** List safe repository records only for the exact subject/project scope. */ + async listSubscriptions({ subject_id: subjectId, project_id: projectId }) { + return listSubscriptions.all(subjectId, projectId).map(normalizeSubscriptionRow); + }, + + /** Resolve the current reusable credential by its one-way SHA-256 hash. */ + async findSubscriptionByHash(secretHash) { + return normalizeSubscriptionRow(findByHash.get(secretHash)); + }, + + /** + * Record one successful use while comparing stored and live membership + * versions in the same SQLite transition. + */ + async recordUsageAtomically(secretHash, binding) { + return withSavepoint(db, USAGE_SAVEPOINT, () => { + const membershipVersion = String(binding.membership_version); + const result = recordUsage.run( + binding.now_ms, + binding.now_ms, + secretHash, + binding.project_id, + binding.audience, + binding.now_ms, + binding.now_ms, + membershipVersion, + membershipVersion, + ); + if (Number(result.changes) !== 1) return null; + const current = findByHash.get(secretHash); + insertUsage.run(current.subscription_id, binding.now_ms); + insertAudit.run( + current.subscription_id, + 'used', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return normalizeSubscriptionRow(current); + }); + }, + + /** + * Atomically replace the sole active secret hash and snapshot the freshly + * rechecked membership version. The prior hash is not retained anywhere. + */ + async rotateSubscriptionAtomically(subscriptionId, binding) { + return withSavepoint(db, ROTATE_SAVEPOINT, () => { + const membershipVersion = assertLiveMembershipVersion( + liveMembershipVersion, + binding.project_id, + binding.subject_id, + binding.membership_version, + ); + const result = replaceSecret.run( + binding.new_secret_hash, + membershipVersion, + binding.expires_at_ms, + binding.now_ms, + subscriptionId, + binding.subject_id, + binding.project_id, + binding.now_ms, + binding.expires_at_ms, + binding.now_ms, + membershipVersion, + ); + if (Number(result.changes) !== 1) return null; + const current = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + insertRotation.run(subscriptionId, binding.now_ms, binding.expires_at_ms); + insertAudit.run( + subscriptionId, + 'rotated', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return normalizeSubscriptionRow(current); + }); + }, + + /** + * Revoke a subscription idempotently. Repeating the same operator action + * returns the already-revoked state without creating duplicate audit facts. + */ + async revokeSubscriptionAtomically(subscriptionId, binding) { + return withSavepoint(db, REVOKE_SAVEPOINT, () => { + const existing = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + if (!existing) return null; + if (existing.revoked_at_ms !== null && existing.revoked_at_ms !== undefined) { + return normalizeSubscriptionRow(existing); + } + revokeSubscription.run( + binding.now_ms, + subscriptionId, + binding.subject_id, + binding.project_id, + ); + const current = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + insertAudit.run( + subscriptionId, + 'revoked', + current.subject_id, + current.project_id, + binding.now_ms, + ); + return normalizeSubscriptionRow(current); + }); + }, + }); +} + +/** + * Create project-management authorization for calendar subscription lifecycle. + * + * The same nondisclosing absence error is used for an unknown project and for a + * project outside the subject's organization. HTTP adapters can therefore map + * the domain's management failure without revealing tenant existence. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertCanManage: Function}} Authorization port. + */ +export function createSqliteCalendarSubscriptionAuthorizationPort(database) { + const db = requireDatabase(database); + const projectAccess = db.prepare(` + SELECT p.id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ? + `); + + return Object.freeze({ + /** Verify that the subject currently belongs to the project's organization. */ + async assertCanManage({ subjectId, projectId }) { + if (!projectAccess.get(projectId, subjectId)) { + throw new Error('calendar_subscription_resource_unavailable'); + } + }, + }); +} + +/** + * Create the live membership-version port used before calendar credential use. + * + * The opaque version combines membership-row identity with the user's session + * token version. Removing/re-adding membership changes the first component; + * logout-all or password/session invalidation changes the second. Repository + * transitions compare this live value in their own savepoint before committing. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertActive: Function}} Membership revocation/version port. + */ +export function createSqliteCalendarSubscriptionMembershipPort(database) { + const db = requireDatabase(database); + const activeMembership = membershipVersionStatement(db); + + return Object.freeze({ + /** Return the current opaque membership/session version for one project. */ + async assertActive({ subjectId, projectId }) { + const row = activeMembership.get(projectId, subjectId); + if (!row?.membership_version) { + throw new Error('calendar_subscription_membership_inactive'); + } + return String(row.membership_version); + }, + }); +} From a4ef7de15edb0ecd88a972c973646a3e13073d79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:52:07 +0900 Subject: [PATCH 05/33] docs(calendar): trace durable SQLite subscription persistence --- .../doctoring/calendar-subscription-sqlite.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/doctoring/calendar-subscription-sqlite.md diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md new file mode 100644 index 00000000..fe66f2dc --- /dev/null +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -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 nine 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 From 34d1bc2af6699c3e0084e3bd5ca754f353b331d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:52:51 +0900 Subject: [PATCH 06/33] docs(calendar): record active SQLite persistence slice --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be5bc6b..dffee2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 From dafe0d8baf55ca91c2f66100cba5413980d7f4a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:54:20 +0900 Subject: [PATCH 07/33] test(calendar): cover SQLite fail-closed edge transitions --- .../calendar-subscription-sqlite.test.mjs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/calendar-subscription-sqlite.test.mjs b/tests/unit/calendar-subscription-sqlite.test.mjs index 71119cf0..55d94ddc 100644 --- a/tests/unit/calendar-subscription-sqlite.test.mjs +++ b/tests/unit/calendar-subscription-sqlite.test.mjs @@ -359,3 +359,57 @@ test('owned schema is normalized, uses descriptive multiword names, and passes f assert.deepEqual(usage, ['usage_event_id', 'subscription_id', 'used_at_ms']); db.close(); }); + +test('adapter dependencies and stale or missing atomic transitions fail closed', async () => { + assert.throws( + () => installCalendarSubscriptionSchema(null), + /requires a database with exec\(\) and prepare\(\)/, + ); + + const db = new DatabaseSync(':memory:'); + installCoreSchema(db); + seed(db); + installCalendarSubscriptionSchema(db); + const repository = createSqliteCalendarSubscriptionRepository(db); + + db.prepare('UPDATE users SET token_version = 1 WHERE id = 1').run(); + await assert.rejects( + repository.insertSubscription({ + subscription_id: 'csub_stale_membership_snapshot', + secret_hash: 'a'.repeat(64), + subject_id: '1', + project_id: '1000', + name: 'Stale membership attempt', + audience: 'scopeweave:calendar', + membership_version: '100:0', + created_at_ms: 1_000_000, + expires_at_ms: 2_000_000, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + }), + /calendar_subscription_membership_inactive/, + 'create must recheck the live membership version inside the persistence boundary', + ); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscriptions').get().count, 0); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox').get().count, 0); + + const missingRotation = await repository.rotateSubscriptionAtomically('csub_missing', { + subject_id: '1', + project_id: '1000', + new_secret_hash: 'b'.repeat(64), + now_ms: 1_000_100, + expires_at_ms: 2_000_000, + membership_version: '100:1', + }); + assert.equal(missingRotation, null); + + const missingRevocation = await repository.revokeSubscriptionAtomically('csub_missing', { + subject_id: '1', + project_id: '1000', + now_ms: 1_000_100, + }); + assert.equal(missingRevocation, null); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox').get().count, 0); + db.close(); +}); From 79fe7a4015a93da6f868049d0c96ca4d669960b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:02:42 +0900 Subject: [PATCH 08/33] test(calendar): expose management revocation race --- ...calendar-subscription-sqlite-race.test.mjs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/unit/calendar-subscription-sqlite-race.test.mjs diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs new file mode 100644 index 00000000..2d089b34 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCalendarSubscriptionService } from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installCoreSchema(database) { + database.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +function createService(database, projectAuthorization) { + return createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(database), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization, + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + }); +} + +function revokingAuthorization(database) { + const authorize = createSqliteCalendarSubscriptionAuthorizationPort(database); + return { + async assertCanManage(binding) { + await authorize.assertCanManage(binding); + database.prepare('DELETE FROM memberships WHERE id = 100').run(); + }, + }; +} + +async function createSubscription(database) { + const service = createService( + database, + createSqliteCalendarSubscriptionAuthorizationPort(database), + ); + return service.create({ + subjectId: '1', + projectId: '1000', + name: 'Race-safe calendar', + expiresAtMs: 2_000_000, + }); +} + +test('list rechecks live membership after the management authorization boundary', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + await createSubscription(database); + + const service = createService(database, revokingAuthorization(database)); + const subscriptions = await service.list({ subjectId: '1', projectId: '1000' }); + + assert.deepEqual( + subscriptions, + [], + 'membership removed after the preflight authorization must not disclose durable subscription metadata', + ); + database.close(); +}); + +test('revoke rechecks live membership before mutating subscription state or audit evidence', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + + const service = createService(database, revokingAuthorization(database)); + await assert.rejects( + service.revoke({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + 'membership removed after the preflight authorization must fail through the nondisclosing management boundary', + ); + + const stored = database.prepare( + 'SELECT revoked_at_ms FROM calendar_subscriptions WHERE subscription_id = ?', + ).get(created.subscriptionId); + assert.equal(stored.revoked_at_ms, null); + assert.equal( + database.prepare( + "SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'", + ).get().count, + 0, + 'failed authorization races must not manufacture durable revocation evidence', + ); + database.close(); +}); From 6d6b9022095c2ac6700180cd6327158017e6ac12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:03:30 +0900 Subject: [PATCH 09/33] test(calendar): run management race regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 10df7c15..704b6640 100644 --- a/package.json +++ b/package.json @@ -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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.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: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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.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 tests/e2e/toast-accessibility.spec.js", From 75ebaaf5ec25ebd8f3ebb08a4186f4ca1906ca90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:05:26 +0900 Subject: [PATCH 10/33] fix(calendar): close management authorization races --- server/calendar_subscription_sqlite.mjs | 55 +++++++++++++++++++++---- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index 2b9159c4..2b9da7cf 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -132,8 +132,10 @@ export function installCalendarSubscriptionSchema(database) { * by the domain with live database membership inside that transaction. Usage * additionally requires the stored snapshot to match the live version in the * same conditional UPDATE, closing membership-removal and session-revocation - * races. Rotation replaces the sole current hash; no historical credential hash - * is copied into history relations. + * races. Management list and revoke queries independently require live project + * membership, so authorization loss between domain preflight and persistence + * cannot disclose metadata or mutate state. Rotation replaces the sole current + * hash; no historical credential hash is copied into history relations. * * @param {object} database Node SQLite-compatible database handle. * @returns {{insertSubscription: Function, listSubscriptions: Function, findSubscriptionByHash: Function, recordUsageAtomically: Function, rotateSubscriptionAtomically: Function, revokeSubscriptionAtomically: Function}} Repository adapter. @@ -151,7 +153,15 @@ export function createSqliteCalendarSubscriptionRepository(database) { const listSubscriptions = db.prepare(` SELECT * FROM calendar_subscriptions - WHERE subject_id = ? AND project_id = ? + WHERE subject_id = ? + AND project_id = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) ORDER BY created_at_ms DESC, subscription_id ASC `); const findByHash = db.prepare('SELECT * FROM calendar_subscriptions WHERE secret_hash = ?'); @@ -160,6 +170,20 @@ export function createSqliteCalendarSubscriptionRepository(database) { FROM calendar_subscriptions WHERE subscription_id = ? AND subject_id = ? AND project_id = ? `); + const findManageableScopedById = db.prepare(` + SELECT * + FROM calendar_subscriptions + WHERE subscription_id = ? + AND subject_id = ? + AND project_id = ? + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) + `); const recordUsage = db.prepare(` UPDATE calendar_subscriptions SET last_used_at_ms = CASE @@ -212,6 +236,13 @@ export function createSqliteCalendarSubscriptionRepository(database) { AND subject_id = ? AND project_id = ? AND revoked_at_ms IS NULL + AND EXISTS ( + SELECT 1 + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = calendar_subscriptions.project_id + AND m.user_id = calendar_subscriptions.subject_id + ) `); const insertRotation = db.prepare(` INSERT INTO subscription_rotations(subscription_id, rotated_at_ms, expires_at_ms) @@ -264,7 +295,10 @@ export function createSqliteCalendarSubscriptionRepository(database) { }); }, - /** List safe repository records only for the exact subject/project scope. */ + /** + * List scoped records only while the subject remains a live member of the + * project's organization at the persistence boundary. + */ async listSubscriptions({ subject_id: subjectId, project_id: projectId }) { return listSubscriptions.all(subjectId, projectId).map(normalizeSubscriptionRow); }, @@ -346,22 +380,27 @@ export function createSqliteCalendarSubscriptionRepository(database) { }, /** - * Revoke a subscription idempotently. Repeating the same operator action - * returns the already-revoked state without creating duplicate audit facts. + * Revoke a subscription idempotently while independently requiring current + * project membership at both the scoped read and mutation boundaries. */ async revokeSubscriptionAtomically(subscriptionId, binding) { return withSavepoint(db, REVOKE_SAVEPOINT, () => { - const existing = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); + const existing = findManageableScopedById.get( + subscriptionId, + binding.subject_id, + binding.project_id, + ); if (!existing) return null; if (existing.revoked_at_ms !== null && existing.revoked_at_ms !== undefined) { return normalizeSubscriptionRow(existing); } - revokeSubscription.run( + const result = revokeSubscription.run( binding.now_ms, subscriptionId, binding.subject_id, binding.project_id, ); + if (Number(result.changes) !== 1) return null; const current = findScopedById.get(subscriptionId, binding.subject_id, binding.project_id); insertAudit.run( subscriptionId, From 699294c0008c415d09e932dc86d6149d436411e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:41:26 +0900 Subject: [PATCH 11/33] test(calendar): expose rotation membership race --- ...calendar-subscription-sqlite-race.test.mjs | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs index 2d089b34..70ea2f82 100644 --- a/tests/unit/calendar-subscription-sqlite-race.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -47,14 +47,18 @@ function deterministicRandomSource() { }; } -function createService(database, projectAuthorization) { +function createService( + database, + projectAuthorization, + membershipRevocation = createSqliteCalendarSubscriptionMembershipPort(database), +) { return createCalendarSubscriptionService({ repository: createSqliteCalendarSubscriptionRepository(database), clock: { nowMs: () => 1_000_000 }, randomSource: deterministicRandomSource(), auditSink: { record: async () => {} }, projectAuthorization, - membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + membershipRevocation, }); } @@ -68,6 +72,17 @@ function revokingAuthorization(database) { }; } +function changingMembershipVersionAfterRead(database) { + const membership = createSqliteCalendarSubscriptionMembershipPort(database); + return { + async assertActive(binding) { + const version = await membership.assertActive(binding); + database.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = 1').run(); + return version; + }, + }; +} + async function createSubscription(database) { const service = createService( database, @@ -98,6 +113,54 @@ test('list rechecks live membership after the management authorization boundary' database.close(); }); +test('rotate maps a membership-version race through the nondisclosing management boundary', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + const before = database.prepare(` + SELECT secret_hash, membership_version, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + + const service = createService( + database, + createSqliteCalendarSubscriptionAuthorizationPort(database), + changingMembershipVersionAfterRead(database), + ); + await assert.rejects( + service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_500_000, + }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + 'a version change after domain preflight must fail through the same tenant-nondisclosing boundary', + ); + + const after = database.prepare(` + SELECT secret_hash, membership_version, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + assert.deepEqual(after, before, 'failed rotation races must leave authorization state unchanged'); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, + 0, + 'failed rotation races must not create lifecycle history', + ); + assert.equal( + database.prepare( + "SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'rotated'", + ).get().count, + 0, + 'failed rotation races must not manufacture durable rotation evidence', + ); + database.close(); +}); + test('revoke rechecks live membership before mutating subscription state or audit evidence', async () => { const database = new DatabaseSync(':memory:'); installCoreSchema(database); From 6e9c3a19ae5ba3eb3c2fe122ab20427e022c5be7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:42:55 +0900 Subject: [PATCH 12/33] fix(calendar): preserve rotation not-found boundary --- server/calendar_subscription_sqlite.mjs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index 2b9da7cf..c5860e6d 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -43,14 +43,27 @@ function membershipVersionStatement(database) { `); } -function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { +function matchLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { const live = statement.get(projectId, subjectId); if (!live?.membership_version || String(live.membership_version) !== String(expectedVersion)) { - throw new Error('calendar_subscription_membership_inactive'); + return null; } return String(live.membership_version); } +function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVersion) { + const membershipVersion = matchLiveMembershipVersion( + statement, + projectId, + subjectId, + expectedVersion, + ); + if (!membershipVersion) { + throw new Error('calendar_subscription_membership_inactive'); + } + return membershipVersion; +} + /** * Install normalized durable storage for reusable calendar subscriptions. * @@ -343,15 +356,18 @@ export function createSqliteCalendarSubscriptionRepository(database) { /** * Atomically replace the sole active secret hash and snapshot the freshly * rechecked membership version. The prior hash is not retained anywhere. + * A membership change after the domain preflight returns `null` so the + * domain preserves its stable tenant-nondisclosing not-found boundary. */ async rotateSubscriptionAtomically(subscriptionId, binding) { return withSavepoint(db, ROTATE_SAVEPOINT, () => { - const membershipVersion = assertLiveMembershipVersion( + const membershipVersion = matchLiveMembershipVersion( liveMembershipVersion, binding.project_id, binding.subject_id, binding.membership_version, ); + if (!membershipVersion) return null; const result = replaceSecret.run( binding.new_secret_hash, membershipVersion, From d2c0e630d7b79bc984845934c8d4674f0a1f55a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 14:44:28 +0900 Subject: [PATCH 13/33] docs(calendar): correct SQLite scenario evidence --- docs/doctoring/calendar-subscription-sqlite.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md index fe66f2dc..8675fb8a 100644 --- a/docs/doctoring/calendar-subscription-sqlite.md +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -97,7 +97,7 @@ Outbox delivery itself is intentionally outside this slice. A later worker may m ## 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 nine focused SQLite behavior scenarios passed together with the repository unit/API suite and cloud browser E2E in the observed hosted run. +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. From 36a16a091ad712321ba3d0bda7da87420d5dd2a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:01:28 +0900 Subject: [PATCH 14/33] test(calendar): expose savepoint cleanup masking --- ...calendar-subscription-sqlite-race.test.mjs | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs index 70ea2f82..8f988372 100644 --- a/tests/unit/calendar-subscription-sqlite-race.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -11,8 +11,8 @@ import { } from '../../server/calendar_subscription_sqlite.mjs'; function installCoreSchema(database) { + database.exec('PRAGMA foreign_keys = ON'); database.exec(` - PRAGMA foreign_keys = ON; CREATE TABLE users ( id INTEGER PRIMARY KEY, token_version INTEGER NOT NULL DEFAULT 0 @@ -191,3 +191,102 @@ test('revoke rechecks live membership before mutating subscription state or audi ); database.close(); }); + +test('foreign-key enforcement rejects invalid durable subscriptions and missing hashes normalize to null', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + + assert.equal( + database.prepare('PRAGMA foreign_keys').get().foreign_keys, + 1, + 'bootstrap test fixture must exercise SQLite foreign-key enforcement rather than integrity inspection alone', + ); + assert.throws( + () => database.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, audience, + membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?,?,NULL,NULL) + `).run( + 'csub_missing_project', + 'c'.repeat(64), + 1, + 999999, + 'Missing project', + 'scopeweave:calendar', + '100:0', + 1_000_000, + 2_000_000, + null, + ), + /FOREIGN KEY constraint failed/, + ); + + const repository = createSqliteCalendarSubscriptionRepository(database); + assert.equal( + await repository.findSubscriptionByHash('f'.repeat(64)), + null, + 'an unknown hash must retain the repository missing-row contract', + ); + database.close(); +}); + +test('savepoint cleanup failure never masks the causal operation error', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + database.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage_cleanup + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + let rolledBack = false; + const cleanupFailingDatabase = { + prepare(sql) { + return database.prepare(sql); + }, + exec(sql) { + if (sql === 'ROLLBACK TO calendar_subscription_usage_state') { + rolledBack = true; + } else if (rolledBack && sql === 'RELEASE calendar_subscription_usage_state') { + throw new Error('forced release cleanup failure'); + } + return database.exec(sql); + }, + }; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(cleanupFailingDatabase), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(database), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + }); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + /forced audit outbox failure/, + 'cleanup failure must not replace the operation error that caused rollback', + ); + assert.equal(rolledBack, true, 'the adapter must attempt rollback before release cleanup'); + assert.equal( + database.prepare( + 'SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?', + ).get(created.subscriptionId).last_used_at_ms, + null, + 'the causal failure must leave authorization state rolled back', + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM subscription_usage_events').get().count, + 0, + 'rollback must remove usage evidence written before the audit failure', + ); + database.close(); +}); From 8511d2942a0fa171cd5cb5babb9ce85b4b21d591 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:03:14 +0900 Subject: [PATCH 15/33] test(calendar): expose rollback cleanup masking --- ...calendar-subscription-sqlite-race.test.mjs | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs index 8f988372..0367e876 100644 --- a/tests/unit/calendar-subscription-sqlite-race.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -233,7 +233,7 @@ test('foreign-key enforcement rejects invalid durable subscriptions and missing database.close(); }); -test('savepoint cleanup failure never masks the causal operation error', async () => { +test('savepoint release cleanup failure never masks the causal operation error', async () => { const database = new DatabaseSync(':memory:'); installCoreSchema(database); installCalendarSubscriptionSchema(database); @@ -273,7 +273,7 @@ test('savepoint cleanup failure never masks the causal operation error', async ( await assert.rejects( service.authorize({ secret: created.secret, projectId: '1000' }), /forced audit outbox failure/, - 'cleanup failure must not replace the operation error that caused rollback', + 'release cleanup failure must not replace the operation error that caused rollback', ); assert.equal(rolledBack, true, 'the adapter must attempt rollback before release cleanup'); assert.equal( @@ -290,3 +290,54 @@ test('savepoint cleanup failure never masks the causal operation error', async ( ); database.close(); }); + +test('savepoint rollback cleanup failure never masks the causal operation error', async () => { + const database = new DatabaseSync(':memory:'); + installCoreSchema(database); + installCalendarSubscriptionSchema(database); + const created = await createSubscription(database); + database.exec(` + CREATE TRIGGER calendar_subscription_test_abort_usage_rollback + BEFORE INSERT ON calendar_subscription_audit_outbox + WHEN NEW.event_type = 'used' + BEGIN + SELECT RAISE(ABORT, 'forced audit outbox failure'); + END; + `); + + let releaseAttempted = false; + const rollbackFailingDatabase = { + prepare(sql) { + return database.prepare(sql); + }, + exec(sql) { + if (sql === 'ROLLBACK TO calendar_subscription_usage_state') { + throw new Error('forced rollback cleanup failure'); + } + if (sql === 'RELEASE calendar_subscription_usage_state') { + releaseAttempted = true; + } + return database.exec(sql); + }, + }; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(rollbackFailingDatabase), + clock: { nowMs: () => 1_000_000 }, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(database), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(database), + }); + + await assert.rejects( + service.authorize({ secret: created.secret, projectId: '1000' }), + /forced audit outbox failure/, + 'rollback cleanup failure must not replace the operation error that caused rollback', + ); + assert.equal( + releaseAttempted, + false, + 'an unconfirmed rollback must not release and accidentally commit the failed savepoint', + ); + database.close(); +}); From 2ebc86bc4793cce26715bc4113bd5bd3a0e8f0ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 15:04:47 +0900 Subject: [PATCH 16/33] fix(calendar): preserve causal savepoint errors --- server/calendar_subscription_sqlite.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index c5860e6d..9d6ebbbf 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -27,8 +27,20 @@ function withSavepoint(database, savepointName, operation) { database.exec(`RELEASE ${savepointName}`); return result; } catch (error) { - database.exec(`ROLLBACK TO ${savepointName}`); - database.exec(`RELEASE ${savepointName}`); + let rollbackSucceeded = false; + try { + database.exec(`ROLLBACK TO ${savepointName}`); + rollbackSucceeded = true; + } catch { + // An unconfirmed rollback must leave the savepoint open rather than risk committing failed state. + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE ${savepointName}`); + } catch { + // Cleanup failure must never replace the causal operation error after state is rolled back. + } + } throw error; } } From 96b91259937b620e5e8acea0e0e81f485bd83b73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:03:45 +0900 Subject: [PATCH 17/33] test(calendar): close SQLite integrity coverage gaps --- .../calendar-subscription-sqlite.test.mjs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/unit/calendar-subscription-sqlite.test.mjs b/tests/unit/calendar-subscription-sqlite.test.mjs index 55d94ddc..96378c6c 100644 --- a/tests/unit/calendar-subscription-sqlite.test.mjs +++ b/tests/unit/calendar-subscription-sqlite.test.mjs @@ -17,8 +17,8 @@ import { } from '../../server/calendar_subscription_sqlite.mjs'; function installCoreSchema(db) { + db.exec('PRAGMA foreign_keys = ON'); db.exec(` - PRAGMA foreign_keys = ON; CREATE TABLE users ( id INTEGER PRIMARY KEY, token_version INTEGER NOT NULL DEFAULT 0 @@ -328,6 +328,26 @@ test('owned schema is normalized, uses descriptive multiword names, and passes f seed(db); installCalendarSubscriptionSchema(db); + assert.equal(db.prepare('PRAGMA foreign_keys').get().foreign_keys, 1); + assert.throws(() => db.prepare(` + INSERT INTO calendar_subscriptions( + subscription_id, secret_hash, subject_id, project_id, name, audience, + membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + rotated_at_ms, revoked_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + `).run( + 'csub_missing_project', + 'c'.repeat(64), + 1, + 999999, + 'Missing project', + 'scopeweave:calendar', + '100:0', + 1_000_000, + 2_000_000, + null, + )); + const owned = db.prepare(` SELECT name, type FROM sqlite_master @@ -410,6 +430,7 @@ test('adapter dependencies and stale or missing atomic transitions fail closed', now_ms: 1_000_100, }); assert.equal(missingRevocation, null); + assert.equal(await repository.findSubscriptionByHash('f'.repeat(64)), null); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox').get().count, 0); db.close(); -}); +}); \ No newline at end of file From fe823b308ed538821c017b1739429bf9661c6195 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:14:59 +0900 Subject: [PATCH 18/33] test(calendar): cover issuance epoch persistence contract --- ...ubscription-sqlite-issuance-epoch.test.mjs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs diff --git a/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs new file mode 100644 index 00000000..8536f088 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installFixture(database) { + database.exec('PRAGMA foreign_keys = ON'); + database.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY + ); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); + installCalendarSubscriptionSchema(database); +} + +const baseRecord = (overrides = {}) => ({ + subscription_id: 'csub_00112233445566778899aabbccddeeff', + secret_hash: 'a'.repeat(64), + subject_id: '1', + project_id: '1000', + name: 'Primary calendar', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '100:0', + created_at_ms: 1_000_000, + expires_at_ms: 2_000_000, + last_used_at_ms: null, + rotated_at_ms: null, + revoked_at_ms: null, + ...overrides, +}); + +test('SQLite subscription storage persists the calendar_read purpose as authorization state', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + + await repository.insertSubscription(baseRecord()); + const stored = await repository.findSubscriptionByHash('a'.repeat(64)); + + assert.equal(stored.purpose, 'calendar_read'); + assert.equal( + database.prepare('SELECT purpose FROM calendar_subscriptions WHERE subscription_id = ?') + .get(baseRecord().subscription_id).purpose, + 'calendar_read', + ); + database.close(); +}); + +test('usage is bound to stored purpose and issuance membership epoch across remove-then-rejoin', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + await repository.insertSubscription(baseRecord()); + + const wrongPurpose = await repository.recordUsageAtomically('a'.repeat(64), { + now_ms: 1_100_000, + project_id: '1000', + purpose: 'session', + audience: 'scopeweave:calendar', + membership_version: '100:0', + }); + assert.equal(wrongPurpose, null, 'calendar credentials must not authorize a broader purpose'); + + database.prepare('DELETE FROM memberships WHERE id = 100').run(); + database.prepare('INSERT INTO memberships(id, org_id, user_id) VALUES (101, 10, 1)').run(); + const afterRejoin = await repository.recordUsageAtomically('a'.repeat(64), { + now_ms: 1_200_000, + project_id: '1000', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '100:0', + }); + assert.equal(afterRejoin, null, 'remove-then-rejoin must not revive the issuance epoch'); + + const rotated = await repository.rotateSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + new_secret_hash: 'b'.repeat(64), + now_ms: 1_300_000, + expires_at_ms: 2_300_000, + purpose: 'calendar_read', + membership_version: '101:0', + }); + assert.equal(rotated.membership_version, '101:0'); + assert.equal(rotated.purpose, 'calendar_read'); + + const rebound = await repository.recordUsageAtomically('b'.repeat(64), { + now_ms: 1_400_000, + project_id: '1000', + purpose: 'calendar_read', + audience: 'scopeweave:calendar', + membership_version: '101:0', + }); + assert.equal(rebound.subscription_id, baseRecord().subscription_id); + database.close(); +}); + +test('revocation reports only the first state transition while preserving the original timestamp', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + await repository.insertSubscription(baseRecord()); + + const first = await repository.revokeSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + now_ms: 1_500_000, + }); + const second = await repository.revokeSubscriptionAtomically(baseRecord().subscription_id, { + subject_id: '1', + project_id: '1000', + now_ms: 1_600_000, + }); + + assert.equal(first.revocation_applied, true); + assert.equal(second.revocation_applied, false); + assert.equal(second.revoked_at_ms, 1_500_000); + assert.equal( + database.prepare("SELECT COUNT(*) AS count FROM calendar_subscription_audit_outbox WHERE event_type = 'revoked'") + .get().count, + 1, + ); + database.close(); +}); From 01991b7de20bf7e3ea3b65ea332e0ed76caea520 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:15:29 +0900 Subject: [PATCH 19/33] test(calendar): run issuance epoch SQLite regressions --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 704b6640..a759c10a 100644 --- a/package.json +++ b/package.json @@ -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/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-return-boundary.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 && node tests/unit/toast-accessibility.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-race.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: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-return-boundary.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 tests/e2e/toast-accessibility.spec.js", From c6392b47a69bf8c6654fa25d75b8a7ee28d28531 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:16:26 +0900 Subject: [PATCH 20/33] fix(calendar): persist purpose and issuance epoch semantics --- server/calendar_subscription_sqlite.mjs | 59 ++++++++++++++++--------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index 9d6ebbbf..d89498d8 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -3,6 +3,7 @@ const USAGE_SAVEPOINT = 'calendar_subscription_usage_state'; const ROTATE_SAVEPOINT = 'calendar_subscription_rotate_state'; const REVOKE_SAVEPOINT = 'calendar_subscription_revoke_state'; const CALENDAR_AUDIENCE = 'scopeweave:calendar'; +const CALENDAR_PURPOSE = 'calendar_read'; function requireDatabase(database) { if (!database || typeof database.exec !== 'function' || typeof database.prepare !== 'function') { @@ -80,11 +81,11 @@ function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVe * Install normalized durable storage for reusable calendar subscriptions. * * The authorization relation stores only the currently active SHA-256 secret - * hash. Rotation and usage relations contain lifecycle facts only and never - * retain either plaintext credentials or historical hashes. The audit outbox - * intentionally has no foreign key to the live subscription so security-event - * evidence survives resource deletion; delivery can therefore be retried by a - * later operator without restoring authorization state. + * hash plus the fixed `calendar_read` purpose and the membership/session epoch + * captured when the credential was issued or rotated. Rotation and usage + * relations contain lifecycle facts only and never retain plaintext credentials + * or historical hashes. The audit outbox intentionally has no foreign key to + * the live subscription so security-event evidence survives resource deletion. * * Call this during database bootstrap with foreign-key enforcement enabled. * Request handlers must never perform schema installation. @@ -101,6 +102,7 @@ export function installCalendarSubscriptionSchema(database) { subject_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 120), + purpose TEXT NOT NULL CHECK(purpose = '${CALENDAR_PURPOSE}'), audience TEXT NOT NULL CHECK(audience = '${CALENDAR_AUDIENCE}'), membership_version TEXT NOT NULL CHECK(length(membership_version) BETWEEN 1 AND 128), created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), @@ -155,9 +157,13 @@ export function installCalendarSubscriptionSchema(database) { * transaction while still rolling back lifecycle state and durable audit-outbox * evidence together. Create and rotate compare the membership version supplied * by the domain with live database membership inside that transaction. Usage - * additionally requires the stored snapshot to match the live version in the - * same conditional UPDATE, closing membership-removal and session-revocation - * races. Management list and revoke queries independently require live project + * binds the supplied issuance epoch to both the stored row and independently + * resolved live membership in the same conditional UPDATE, so remove-then-rejoin + * cannot revive a durable calendar credential. Purpose and audience are checked + * at the same persistence boundary. Rotation is the only path that can bind the + * credential to a newly authorized membership epoch. + * + * Management list and revoke queries independently require live project * membership, so authorization loss between domain preflight and persistence * cannot disclose metadata or mutate state. Rotation replaces the sole current * hash; no historical credential hash is copied into history relations. @@ -170,10 +176,10 @@ export function createSqliteCalendarSubscriptionRepository(database) { const liveMembershipVersion = membershipVersionStatement(db); const insertSubscription = db.prepare(` INSERT INTO calendar_subscriptions( - subscription_id, secret_hash, subject_id, project_id, name, audience, - membership_version, created_at_ms, expires_at_ms, last_used_at_ms, + subscription_id, secret_hash, subject_id, project_id, name, purpose, + audience, membership_version, created_at_ms, expires_at_ms, last_used_at_ms, rotated_at_ms, revoked_at_ms - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) `); const listSubscriptions = db.prepare(` SELECT * @@ -217,6 +223,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { END WHERE secret_hash = ? AND project_id = ? + AND purpose = ? AND audience = ? AND revoked_at_ms IS NULL AND ? >= created_at_ms @@ -241,6 +248,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { WHERE subscription_id = ? AND subject_id = ? AND project_id = ? + AND purpose = ? AND revoked_at_ms IS NULL AND ? >= created_at_ms AND ? > ? @@ -302,6 +310,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { record.subject_id, record.project_id, record.name, + record.purpose, record.audience, membershipVersion, record.created_at_ms, @@ -334,8 +343,8 @@ export function createSqliteCalendarSubscriptionRepository(database) { }, /** - * Record one successful use while comparing stored and live membership - * versions in the same SQLite transition. + * Record one successful use while comparing the stored issuance epoch with + * the caller-supplied issuance epoch and live membership in one transition. */ async recordUsageAtomically(secretHash, binding) { return withSavepoint(db, USAGE_SAVEPOINT, () => { @@ -345,6 +354,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { binding.now_ms, secretHash, binding.project_id, + binding.purpose, binding.audience, binding.now_ms, binding.now_ms, @@ -366,10 +376,10 @@ export function createSqliteCalendarSubscriptionRepository(database) { }, /** - * Atomically replace the sole active secret hash and snapshot the freshly - * rechecked membership version. The prior hash is not retained anywhere. - * A membership change after the domain preflight returns `null` so the - * domain preserves its stable tenant-nondisclosing not-found boundary. + * Atomically replace the sole active secret hash and bind it to the current + * live membership version. The prior hash is not retained anywhere. A + * membership change after the domain preflight returns `null` so the domain + * preserves its stable tenant-nondisclosing not-found boundary. */ async rotateSubscriptionAtomically(subscriptionId, binding) { return withSavepoint(db, ROTATE_SAVEPOINT, () => { @@ -388,6 +398,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { subscriptionId, binding.subject_id, binding.project_id, + binding.purpose, binding.now_ms, binding.expires_at_ms, binding.now_ms, @@ -409,7 +420,9 @@ export function createSqliteCalendarSubscriptionRepository(database) { /** * Revoke a subscription idempotently while independently requiring current - * project membership at both the scoped read and mutation boundaries. + * project membership. `revocation_applied` is true only for the first state + * transition so callers cannot emit duplicate external audit events on a + * same-millisecond or later retry. */ async revokeSubscriptionAtomically(subscriptionId, binding) { return withSavepoint(db, REVOKE_SAVEPOINT, () => { @@ -420,7 +433,10 @@ export function createSqliteCalendarSubscriptionRepository(database) { ); if (!existing) return null; if (existing.revoked_at_ms !== null && existing.revoked_at_ms !== undefined) { - return normalizeSubscriptionRow(existing); + return { + ...normalizeSubscriptionRow(existing), + revocation_applied: false, + }; } const result = revokeSubscription.run( binding.now_ms, @@ -437,7 +453,10 @@ export function createSqliteCalendarSubscriptionRepository(database) { current.project_id, binding.now_ms, ); - return normalizeSubscriptionRow(current); + return { + ...normalizeSubscriptionRow(current), + revocation_applied: true, + }; }); }, }); From 09041875840d7efa4d0002f30d1d95a3f868b643 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:20:10 +0000 Subject: [PATCH 21/33] fix(calendar): bind omitted purpose to calendar_read The #524 purpose column rejected parent-domain create/use/rotate because is absent, keep explicit non-calendar values rejectable, and restore the foreign-key inserts so they fail for a missing project rather than NOT NULL. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 5 ++++- CHANGELOG.md | 8 ++++--- .../doctoring/calendar-subscription-sqlite.md | 5 +++-- server/calendar_subscription_sqlite.mjs | 22 ++++++++++++++++--- ...ubscription-sqlite-issuance-epoch.test.mjs | 14 ++++++++++++ ...calendar-subscription-sqlite-race.test.mjs | 5 +++-- .../calendar-subscription-sqlite.test.mjs | 8 ++++--- tests/unit/coverage-script-contract.test.mjs | 10 +++++++++ 8 files changed, 63 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c6e39610..bbe7487d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,4 +49,7 @@ `calendar_read` subscriptions are separate credential domains. Calendar use binds the stored issuance membership epoch; rotation re-binds the live epoch and invalidates the previous secret. Neither domain may - restore a general session JWT in a URL. + restore a general session JWT in a URL. The domain (#539) owns + lifecycle rules; SQLite persistence (#541) stores only the current + hash, frozen purpose, and issuance membership epoch. Route and UI + migration remain later slices under issue #413. diff --git a/CHANGELOG.md b/CHANGELOG.md index dffee2cf..91300037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,10 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 + storage, a frozen `calendar_read` purpose, issuance-epoch membership binding, + atomic live-membership checks, durable rotation/usage evidence, first-transition + revocation evidence, a secret-free audit outbox, restart-survival tests, and c8 registration. Protected route and customer UI migration remain follow-up - work under issue #413. + work under issue #413. The adapter still accepts a parent domain that omits + `purpose` so #514 create/use/rotate remain bindable before #539 lands. ### Security diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md index 8675fb8a..a84e9a64 100644 --- a/docs/doctoring/calendar-subscription-sqlite.md +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -2,7 +2,7 @@ > **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. +> **Stack:** issue #413 → access-grant domain (#506) → calendar-subscription domain (#514, with issuance-epoch landing #539) → SQLite persistence (#524). This slice deliberately does **not** change the protected calendar HTTP route, browser UI, deployment topology, or release version. The parent domain on #514 still emits audience without `purpose`; this adapter freezes `calendar_read` at rest so a later domain rebase can send the field explicitly without a schema change. ## Problem and bounded outcome @@ -36,6 +36,7 @@ erDiagram integer subject_id FK integer project_id FK text name + text purpose text audience text membership_version integer created_at_ms @@ -77,7 +78,7 @@ All owned table/index names contain multiple lexical words and use snake_case. T 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. +3. Calendar audience is fixed to `scopeweave:calendar` and purpose is frozen to `calendar_read`. A parent domain that still omits `purpose` receives that frozen value at the persistence boundary; an explicit non-calendar purpose cannot authorize or persist. 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. diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index d89498d8..b3a45a43 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -77,6 +77,22 @@ function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVe return membershipVersion; } +/** + * Freeze the ICS-only purpose when a parent domain record omits it. + * + * The current stacked parent (#514) still emits audience without purpose. + * Persistence must not fail closed on that omission, and it must not accept a + * broader purpose such as a session credential. An explicit non-calendar value + * is passed through so the conditional UPDATE/INSERT CHECK can reject it. + * + * @param {unknown} value Caller-supplied purpose, if any. + * @returns {string} `calendar_read` or the explicit supplied value. + */ +function resolveCalendarPurpose(value) { + if (value == null || value === '') return CALENDAR_PURPOSE; + return String(value); +} + /** * Install normalized durable storage for reusable calendar subscriptions. * @@ -310,7 +326,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { record.subject_id, record.project_id, record.name, - record.purpose, + resolveCalendarPurpose(record.purpose), record.audience, membershipVersion, record.created_at_ms, @@ -354,7 +370,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { binding.now_ms, secretHash, binding.project_id, - binding.purpose, + resolveCalendarPurpose(binding.purpose), binding.audience, binding.now_ms, binding.now_ms, @@ -398,7 +414,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { subscriptionId, binding.subject_id, binding.project_id, - binding.purpose, + resolveCalendarPurpose(binding.purpose), binding.now_ms, binding.expires_at_ms, binding.now_ms, diff --git a/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs index 8536f088..c1951c3c 100644 --- a/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs @@ -52,6 +52,20 @@ const baseRecord = (overrides = {}) => ({ ...overrides, }); +test('omitted parent-domain purpose still persists as calendar_read', async () => { + const database = new DatabaseSync(':memory:'); + installFixture(database); + const repository = createSqliteCalendarSubscriptionRepository(database); + const { purpose, ...recordWithoutPurpose } = baseRecord(); + + await repository.insertSubscription(recordWithoutPurpose); + const stored = await repository.findSubscriptionByHash('a'.repeat(64)); + + assert.equal(purpose, 'calendar_read'); + assert.equal(stored.purpose, 'calendar_read'); + database.close(); +}); + test('SQLite subscription storage persists the calendar_read purpose as authorization state', async () => { const database = new DatabaseSync(':memory:'); installFixture(database); diff --git a/tests/unit/calendar-subscription-sqlite-race.test.mjs b/tests/unit/calendar-subscription-sqlite-race.test.mjs index 0367e876..c9d53591 100644 --- a/tests/unit/calendar-subscription-sqlite-race.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-race.test.mjs @@ -205,16 +205,17 @@ test('foreign-key enforcement rejects invalid durable subscriptions and missing assert.throws( () => database.prepare(` INSERT INTO calendar_subscriptions( - subscription_id, secret_hash, subject_id, project_id, name, audience, + subscription_id, secret_hash, subject_id, project_id, name, purpose, audience, membership_version, created_at_ms, expires_at_ms, last_used_at_ms, rotated_at_ms, revoked_at_ms - ) VALUES(?,?,?,?,?,?,?,?,?,?,NULL,NULL) + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,NULL,NULL) `).run( 'csub_missing_project', 'c'.repeat(64), 1, 999999, 'Missing project', + 'calendar_read', 'scopeweave:calendar', '100:0', 1_000_000, diff --git a/tests/unit/calendar-subscription-sqlite.test.mjs b/tests/unit/calendar-subscription-sqlite.test.mjs index 96378c6c..e6b3d54a 100644 --- a/tests/unit/calendar-subscription-sqlite.test.mjs +++ b/tests/unit/calendar-subscription-sqlite.test.mjs @@ -106,6 +106,7 @@ test('SQLite calendar adapter persists hash-only reusable state and safe list me assert.equal(stored.subject_id, 1); assert.equal(stored.project_id, 1000); assert.equal(stored.membership_version, '100:0'); + assert.equal(stored.purpose, 'calendar_read'); assert.equal(stored.secret_hash, secretHash(created.secret)); assert.notEqual(stored.secret_hash, created.secret); assert.equal(Object.hasOwn(stored, 'secret'), false); @@ -331,22 +332,23 @@ test('owned schema is normalized, uses descriptive multiword names, and passes f assert.equal(db.prepare('PRAGMA foreign_keys').get().foreign_keys, 1); assert.throws(() => db.prepare(` INSERT INTO calendar_subscriptions( - subscription_id, secret_hash, subject_id, project_id, name, audience, + subscription_id, secret_hash, subject_id, project_id, name, purpose, audience, membership_version, created_at_ms, expires_at_ms, last_used_at_ms, rotated_at_ms, revoked_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL) `).run( 'csub_missing_project', 'c'.repeat(64), 1, 999999, 'Missing project', + 'calendar_read', 'scopeweave:calendar', '100:0', 1_000_000, 2_000_000, null, - )); + ), /FOREIGN KEY constraint failed/); const owned = db.prepare(` SELECT name, type diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 220263f2..32ed191f 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -79,6 +79,16 @@ assert.match( /tests\/unit\/calendar-subscription-sqlite\.test\.mjs/, 'the calendar-subscription SQLite persistence contract executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-sqlite-race\.test\.mjs/, + 'the calendar-subscription SQLite race contract executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/calendar-subscription-sqlite-issuance-epoch\.test\.mjs/, + 'the calendar-subscription issuance-epoch persistence contract executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, From fd83b9d2617d84bc28921f85dfa83bc03244c579 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:51:43 +0900 Subject: [PATCH 22/33] docs(calendar): reconcile adapter architecture onto issuance-epoch parent --- ARCHITECTURE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bbe7487d..be02c99e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,5 +51,6 @@ live epoch and invalidates the previous secret. Neither domain may restore a general session JWT in a URL. The domain (#539) owns lifecycle rules; SQLite persistence (#541) stores only the current - hash, frozen purpose, and issuance membership epoch. Route and UI - migration remain later slices under issue #413. + secret hash, frozen `calendar_read` purpose, issuance membership epoch, + and normalized lifecycle/audit evidence. Protected route and browser UI + migration remain later issue #413 slices. From e7183908656a4a5f056843dfb15da432b81b94ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:52:13 +0900 Subject: [PATCH 23/33] docs(calendar): reconcile SQLite persistence changelog --- CHANGELOG.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91300037..9105f0ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,14 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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, a frozen `calendar_read` purpose, issuance-epoch membership binding, - atomic live-membership checks, durable rotation/usage evidence, first-transition +- **Active stacked PR #541; not yet protected-`develop` truth:** added normalized + SQLite persistence for calendar-subscription credentials with current-hash-only + storage, frozen `calendar_read` purpose, issuance membership-epoch binding, + atomic membership checks, durable rotation/usage evidence, first-transition revocation evidence, a secret-free audit outbox, restart-survival tests, and - c8 registration. Protected route and customer UI migration remain follow-up - work under issue #413. The adapter still accepts a parent domain that omits - `purpose` so #514 create/use/rotate remain bindable before #539 lands. + c8 registration. Protected route and customer UI migration remain later #413 + slices. ### Security From e1390a342188504fd7b60670ae8a10878e595eaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:56:04 +0900 Subject: [PATCH 24/33] docs(calendar): reconcile SQLite doctoring with current stack --- .../doctoring/calendar-subscription-sqlite.md | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md index a84e9a64..35e00fc3 100644 --- a/docs/doctoring/calendar-subscription-sqlite.md +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -2,20 +2,20 @@ > **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, with issuance-epoch landing #539) → SQLite persistence (#524). This slice deliberately does **not** change the protected calendar HTTP route, browser UI, deployment topology, or release version. The parent domain on #514 still emits audience without `purpose`; this adapter freezes `calendar_read` at rest so a later domain rebase can send the field explicitly without a schema change. +> **Stack:** issue #413 → access-grant domain (#506) → calendar-subscription issuance-epoch domain (#539) → SQLite persistence (#541). This slice deliberately does **not** change the protected calendar HTTP route, browser UI, deployment topology, or release version. Current #539 supplies explicit `purpose: calendar_read`; the adapter retains the omitted-purpose default only as a bounded compatibility guard for predecessor/older callers and never broadens an explicit non-calendar purpose. ## 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 protected calendar feed still depends on a broad session credential transported in a URL. Parent PR #539 defines a separately revocable, project-bound reusable credential lifecycle and binds use to the membership epoch captured when the secret was issued, but intentionally contains no production persistence. PR #541 supplies the durable SQLite adapter needed to make that lifecycle survivable across process restarts while enforcing tenant/session revocation in 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. +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 issue #413 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; +- `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. @@ -70,20 +70,20 @@ erDiagram } ``` -`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. +`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 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. +All owned table/index names contain multiple lexical words and use snake_case. The focused schema test 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. +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` and purpose is frozen to `calendar_read`. A parent domain that still omits `purpose` receives that frozen value at the persistence boundary; an explicit non-calendar purpose cannot authorize or persist. +3. Calendar audience is fixed to `scopeweave:calendar` and purpose is frozen to `calendar_read`. Current #539 sends the purpose explicitly. If a predecessor/older caller omits it, the adapter supplies only `calendar_read`; an explicit non-calendar purpose is preserved for the database CHECK/authorization boundary to reject. 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. +5. Use performs a conditional update that simultaneously verifies current hash, project, purpose, audience, non-revocation, pre-expiry time, stored issuance membership epoch, 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. +8. Revocation is operator-idempotent: repeated authenticated revoke requests return the already-revoked state without duplicating the durable revocation event; `revocation_applied` is true only for the first transition. 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. @@ -92,15 +92,17 @@ This credential is ScopeWeave-specific and must **not** be represented as an OAu 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. +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 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. +The original predecessor line began with a test-only head that imported the absent `server/calendar_subscription_sqlite.mjs`; its hosted Server Tests failed RED with `ERR_MODULE_NOT_FOUND`, demonstrating that production implementation was required. Subsequent predecessor runs exercised the implemented adapter and focused SQLite scenarios, but that historical evidence is not merge authorization for current #541. -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. +The old #524 Server Tests also checked out a synthetic `refs/pull/524/merge` revision rather than its contributor head. That evidence remains historical only. PR #523 addresses the repository workflow checkout defect; exact-current-head evidence must be regenerated for the retargeted #541 head and no predecessor/synthetic success transfers. + +During current stack reconciliation, #541 was rebuilt so the effective tree is the exact #539 parent plus the SQLite adapter/docs/tests/coverage registration only. A fresh comparison after reconstruction showed zero commits behind #539 and no parent source/test/documentation regression in the effective diff. Retargeting #541 directly to the #539 head branch therefore makes dependency order explicit instead of relying on a stale sibling base. Focused acceptance coverage includes: @@ -108,32 +110,35 @@ Focused acceptance coverage includes: - 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; +- omitted-purpose compatibility default plus explicit wrong-purpose rejection; - absence of secrets/hashes from rotation, usage, and audit history relations; -- idempotent revocation with one durable revoke event; +- first-transition-only revocation evidence and idempotent repeated revoke; - 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. +- schema naming, normalized history relations, and foreign-key integrity; +- race coverage for membership/rotation/use transitions. -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. +The canonical unit and c8 commands include `server/calendar_subscription_sqlite.mjs` plus the persistence, race, and issuance-epoch suites. Exact 100% statement/branch/function/line evidence remains mandatory before this PR can be considered integration-ready; an ordinary 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 | +| Reusable calendar secret is never plaintext at rest | hash-only persistence/list assertions | `calendar_subscriptions.secret_hash` | Active PR #541 | +| Old secret is unusable after rotation | old/new authorization regression | `rotateSubscriptionAtomically` | Active PR #541 | +| Logout-all/session revocation invalidates subscription | `token_version` mutation regression | membership version + atomic use SQL | Active PR #541 | +| Membership removal/re-add does not revive old credential | membership-row replacement regression | opaque `membership_id:token_version` | Active PR #541 | +| Wrong or broader purpose cannot authorize | explicit wrong-purpose regressions + SQL CHECK | `purpose = calendar_read` | Active PR #541 | +| Cross-tenant project existence is not disclosed | other-tenant list/rotate regression | authorization port + parent domain mapping | Active PR #541 | +| State and audit evidence cannot diverge on write failure | forced-outbox-failure rollback regression | savepoint transaction | Active PR #541 | +| Durable credential survives process restart | file-backed SQLite reopen regression | SQLite repository | Active PR #541 | +| History contains no credential material | schema introspection assertions | rotation/usage/audit relations | Active PR #541 | +| DB object naming and referential integrity | exact object list + `foreign_key_check` | schema installer | Active PR #541 | +| Exact-head CI evidence | current contributor SHA and exact current base required | repository/organization workflows | Regenerating after retarget; predecessor evidence non-passing | +| Independent current-head approval | qualifying independent reviewer after latest push | protected branch/ruleset governance | Required before integration | | 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 | +| Customer can create/copy/rotate/revoke subscription in UI | future implementation matching issue #413 interaction contract | browser client | Planned / out of this slice | ## Rollback and recovery From 127503cdfe98409ed7170472f613fcd207079781 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:50:51 -0700 Subject: [PATCH 25/33] test(calendar): expose duplicate secret-hash index --- package.json | 4 +-- ...endar-subscription-sqlite-indexes.test.mjs | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 tests/unit/calendar-subscription-sqlite-indexes.test.mjs diff --git a/package.json b/package.json index 8a6dacae..aae81bee 100644 --- a/package.json +++ b/package.json @@ -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 && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.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 && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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 && node tests/unit/toast-accessibility.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-return-boundary.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/orchestrator-attribution.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: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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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/orchestrator-attribution.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 tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/calendar-subscription-sqlite-indexes.test.mjs b/tests/unit/calendar-subscription-sqlite-indexes.test.mjs new file mode 100644 index 00000000..c3139df8 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-indexes.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { installCalendarSubscriptionSchema } from '../../server/calendar_subscription_sqlite.mjs'; + +test('calendar subscription schema keeps exactly one secret-hash index', () => { + const db = new DatabaseSync(':memory:'); + installCalendarSubscriptionSchema(db); + + const indexes = db.prepare("PRAGMA index_list('calendar_subscriptions')").all(); + const secretHashIndexes = indexes.filter(({ name }) => { + const quotedName = String(name).replaceAll('"', '""'); + const columns = db.prepare(`PRAGMA index_info("${quotedName}")`).all().map(({ name: columnName }) => columnName); + return columns.length === 1 && columns[0] === 'secret_hash'; + }); + + assert.equal( + secretHashIndexes.length, + 1, + 'UNIQUE(secret_hash) already supplies the only B-tree needed for credential lookup', + ); + assert.equal(Number(secretHashIndexes[0].unique), 1); + assert.equal(secretHashIndexes[0].origin, 'u'); + db.close(); +}); From 2807d8a08fd23cb9984ce8e3f7f74ade4e455d2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:57:46 -0700 Subject: [PATCH 26/33] fix(calendar): remove duplicate secret hash index --- server/calendar_subscription_sqlite.mjs | 62 +------------------ ...endar-subscription-sqlite-indexes.test.mjs | 6 +- 2 files changed, 6 insertions(+), 62 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index b3a45a43..11f4ee3a 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -80,7 +80,7 @@ function assertLiveMembershipVersion(statement, projectId, subjectId, expectedVe /** * Freeze the ICS-only purpose when a parent domain record omits it. * - * The current stacked parent (#514) still emits audience without purpose. + * The current stacked parent (#539) still emits audience without purpose. * Persistence must not fail closed on that omission, and it must not accept a * broader purpose such as a session credential. An explicit non-calendar value * is passed through so the conditional UPDATE/INSERT CHECK can reject it. @@ -114,7 +114,7 @@ export function installCalendarSubscriptionSchema(database) { db.exec(` CREATE TABLE IF NOT EXISTS calendar_subscriptions ( subscription_id TEXT PRIMARY KEY, - secret_hash TEXT NOT NULL UNIQUE CHECK(length(secret_hash) = 64), + secret_hash TEXT NOT NULL CHECK(length(secret_hash) = 64), subject_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 120), @@ -130,7 +130,7 @@ export function installCalendarSubscriptionSchema(database) { CHECK(rotated_at_ms IS NULL OR rotated_at_ms >= created_at_ms), CHECK(revoked_at_ms IS NULL OR revoked_at_ms >= created_at_ms) ); - CREATE INDEX IF NOT EXISTS calendar_subscription_secret_hash_index + CREATE UNIQUE INDEX IF NOT EXISTS calendar_subscription_secret_hash_index ON calendar_subscriptions(secret_hash); CREATE INDEX IF NOT EXISTS calendar_subscription_subject_project_index ON calendar_subscriptions(subject_id, project_id, revoked_at_ms, expires_at_ms); @@ -477,59 +477,3 @@ export function createSqliteCalendarSubscriptionRepository(database) { }, }); } - -/** - * Create project-management authorization for calendar subscription lifecycle. - * - * The same nondisclosing absence error is used for an unknown project and for a - * project outside the subject's organization. HTTP adapters can therefore map - * the domain's management failure without revealing tenant existence. - * - * @param {object} database Node SQLite-compatible database handle. - * @returns {{assertCanManage: Function}} Authorization port. - */ -export function createSqliteCalendarSubscriptionAuthorizationPort(database) { - const db = requireDatabase(database); - const projectAccess = db.prepare(` - SELECT p.id - FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE p.id = ? AND m.user_id = ? - `); - - return Object.freeze({ - /** Verify that the subject currently belongs to the project's organization. */ - async assertCanManage({ subjectId, projectId }) { - if (!projectAccess.get(projectId, subjectId)) { - throw new Error('calendar_subscription_resource_unavailable'); - } - }, - }); -} - -/** - * Create the live membership-version port used before calendar credential use. - * - * The opaque version combines membership-row identity with the user's session - * token version. Removing/re-adding membership changes the first component; - * logout-all or password/session invalidation changes the second. Repository - * transitions compare this live value in their own savepoint before committing. - * - * @param {object} database Node SQLite-compatible database handle. - * @returns {{assertActive: Function}} Membership revocation/version port. - */ -export function createSqliteCalendarSubscriptionMembershipPort(database) { - const db = requireDatabase(database); - const activeMembership = membershipVersionStatement(db); - - return Object.freeze({ - /** Return the current opaque membership/session version for one project. */ - async assertActive({ subjectId, projectId }) { - const row = activeMembership.get(projectId, subjectId); - if (!row?.membership_version) { - throw new Error('calendar_subscription_membership_inactive'); - } - return String(row.membership_version); - }, - }); -} diff --git a/tests/unit/calendar-subscription-sqlite-indexes.test.mjs b/tests/unit/calendar-subscription-sqlite-indexes.test.mjs index c3139df8..b2680b3a 100644 --- a/tests/unit/calendar-subscription-sqlite-indexes.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-indexes.test.mjs @@ -18,9 +18,9 @@ test('calendar subscription schema keeps exactly one secret-hash index', () => { assert.equal( secretHashIndexes.length, 1, - 'UNIQUE(secret_hash) already supplies the only B-tree needed for credential lookup', + 'credential lookup must maintain exactly one secret_hash B-tree', ); - assert.equal(Number(secretHashIndexes[0].unique), 1); - assert.equal(secretHashIndexes[0].origin, 'u'); + assert.equal(Number(secretHashIndexes[0].unique), 1, 'credential hashes must remain unique'); + assert.equal(secretHashIndexes[0].name, 'calendar_subscription_secret_hash_index'); db.close(); }); From 2777c796bfc030245241e74afd86b328c3bd763c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:21:02 -0700 Subject: [PATCH 27/33] fix(calendar): restore SQLite authorization ports --- server/calendar_subscription_sqlite.mjs | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index 11f4ee3a..6681889c 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -477,3 +477,59 @@ export function createSqliteCalendarSubscriptionRepository(database) { }, }); } + +/** + * Create project-management authorization for calendar subscription lifecycle. + * + * The same nondisclosing absence error is used for an unknown project and for a + * project outside the subject's organization. HTTP adapters can therefore map + * the domain's management failure without revealing tenant existence. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertCanManage: Function}} Authorization port. + */ +export function createSqliteCalendarSubscriptionAuthorizationPort(database) { + const db = requireDatabase(database); + const projectAccess = db.prepare(` + SELECT p.id + FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ? + `); + + return Object.freeze({ + /** Verify that the subject currently belongs to the project's organization. */ + async assertCanManage({ subjectId, projectId }) { + if (!projectAccess.get(projectId, subjectId)) { + throw new Error('calendar_subscription_resource_unavailable'); + } + }, + }); +} + +/** + * Create the live membership-version port used before calendar credential use. + * + * The opaque version combines membership-row identity with the user's session + * token version. Removing/re-adding membership changes the first component; + * logout-all or password/session invalidation changes the second. Repository + * transitions compare this live value in their own savepoint before committing. + * + * @param {object} database Node SQLite-compatible database handle. + * @returns {{assertActive: Function}} Membership revocation/version port. + */ +export function createSqliteCalendarSubscriptionMembershipPort(database) { + const db = requireDatabase(database); + const activeMembership = membershipVersionStatement(db); + + return Object.freeze({ + /** Return the current opaque membership/session version for one project. */ + async assertActive({ subjectId, projectId }) { + const row = activeMembership.get(projectId, subjectId); + if (!row?.membership_version) { + throw new Error('calendar_subscription_membership_inactive'); + } + return String(row.membership_version); + }, + }); +} From e7aeffaf9dff905ec968392693b0fa5debeb464e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:28:47 -0700 Subject: [PATCH 28/33] test(calendar): reject expired subscription rotation --- package.json | 4 +- ...lendar-subscription-sqlite-expiry.test.mjs | 104 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 tests/unit/calendar-subscription-sqlite-expiry.test.mjs diff --git a/package.json b/package.json index aae81bee..90b77805 100644 --- a/package.json +++ b/package.json @@ -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 && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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 && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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 && node tests/unit/toast-accessibility.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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/orchestrator-attribution.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: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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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/orchestrator-attribution.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 tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/calendar-subscription-sqlite-expiry.test.mjs b/tests/unit/calendar-subscription-sqlite-expiry.test.mjs new file mode 100644 index 00000000..65e67740 --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-expiry.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCalendarSubscriptionService } from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installFixture(db) { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs (id INTEGER PRIMARY KEY); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); + installCalendarSubscriptionSchema(db); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +test('expired calendar subscription cannot be revived by rotation', async () => { + const db = new DatabaseSync(':memory:'); + installFixture(db); + const clock = { + value: 1_000_000, + nowMs() { + return this.value; + }, + }; + const service = createCalendarSubscriptionService({ + repository: createSqliteCalendarSubscriptionRepository(db), + clock, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(db), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(db), + }); + + const created = await service.create({ + subjectId: '1', + projectId: '1000', + name: 'Expiry boundary', + expiresAtMs: 1_000_100, + }); + const before = db.prepare(` + SELECT secret_hash, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + + clock.value = created.expiresAtMs; + await assert.rejects( + service.rotate({ + subjectId: '1', + projectId: '1000', + subscriptionId: created.subscriptionId, + expiresAtMs: 2_000_000, + }), + (error) => error?.code === 'calendar_subscription_not_found' && error?.status === 404, + 'exact expiry must be terminal for rotation as well as authorization', + ); + + const after = db.prepare(` + SELECT secret_hash, expires_at_ms, rotated_at_ms + FROM calendar_subscriptions + WHERE subscription_id = ? + `).get(created.subscriptionId); + assert.deepEqual(after, before, 'failed rotation must not revive or mutate expired durable state'); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, 0); + assert.deepEqual( + db.prepare('SELECT event_type FROM calendar_subscription_audit_outbox ORDER BY audit_event_id').all(), + [{ event_type: 'created' }], + 'failed rotation must not append durable rotation evidence', + ); + db.close(); +}); From 76f825f80e6af0a63890bd1fa00e7a8e0a46949e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:56:38 -0700 Subject: [PATCH 29/33] fix(calendar): prevent rotating expired subscriptions --- server/calendar_subscription_sqlite.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index 6681889c..dbc85ff3 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -267,6 +267,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { AND purpose = ? AND revoked_at_ms IS NULL AND ? >= created_at_ms + AND ? < expires_at_ms AND ? > ? AND EXISTS ( SELECT 1 @@ -416,6 +417,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { binding.project_id, resolveCalendarPurpose(binding.purpose), binding.now_ms, + binding.now_ms, binding.expires_at_ms, binding.now_ms, membershipVersion, From 7fe0a5aa16db781b9083feb0461e43af44d12906 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 10:19:29 -0700 Subject: [PATCH 30/33] test(calendar): normalize sqlite audit rows in expiry regression --- tests/unit/calendar-subscription-sqlite-expiry.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/calendar-subscription-sqlite-expiry.test.mjs b/tests/unit/calendar-subscription-sqlite-expiry.test.mjs index 65e67740..7c5388d1 100644 --- a/tests/unit/calendar-subscription-sqlite-expiry.test.mjs +++ b/tests/unit/calendar-subscription-sqlite-expiry.test.mjs @@ -95,8 +95,12 @@ test('expired calendar subscription cannot be revived by rotation', async () => `).get(created.subscriptionId); assert.deepEqual(after, before, 'failed rotation must not revive or mutate expired durable state'); assert.equal(db.prepare('SELECT COUNT(*) AS count FROM subscription_rotations').get().count, 0); + const auditEvents = db + .prepare('SELECT event_type FROM calendar_subscription_audit_outbox ORDER BY audit_event_id') + .all() + .map(({ event_type }) => ({ event_type })); assert.deepEqual( - db.prepare('SELECT event_type FROM calendar_subscription_audit_outbox ORDER BY audit_event_id').all(), + auditEvents, [{ event_type: 'created' }], 'failed rotation must not append durable rotation evidence', ); From 09911affd075c5598bfb4869bcfb49f35138f98e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 10:28:50 -0700 Subject: [PATCH 31/33] test(calendar): expose unbounded polling evidence --- package.json | 4 +- ...dar-subscription-sqlite-retention.test.mjs | 105 ++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 tests/unit/calendar-subscription-sqlite-retention.test.mjs diff --git a/package.json b/package.json index 90b77805..0a7eda09 100644 --- a/package.json +++ b/package.json @@ -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 && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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 && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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/orchestrator-attribution.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-retention.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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 && node tests/unit/toast-accessibility.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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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/orchestrator-attribution.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: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-return-boundary.test.mjs && node tests/unit/calendar-subscription-sqlite.test.mjs && node tests/unit/calendar-subscription-sqlite-expiry.test.mjs && node tests/unit/calendar-subscription-sqlite-retention.test.mjs && node tests/unit/calendar-subscription-sqlite-indexes.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/orchestrator-attribution.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 tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/calendar-subscription-sqlite-retention.test.mjs b/tests/unit/calendar-subscription-sqlite-retention.test.mjs new file mode 100644 index 00000000..3bcbc09d --- /dev/null +++ b/tests/unit/calendar-subscription-sqlite-retention.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCalendarSubscriptionService } from '../../server/calendar_subscription_domain.mjs'; +import { + createSqliteCalendarSubscriptionAuthorizationPort, + createSqliteCalendarSubscriptionMembershipPort, + createSqliteCalendarSubscriptionRepository, + installCalendarSubscriptionSchema, +} from '../../server/calendar_subscription_sqlite.mjs'; + +function installFixture(db) { + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + token_version INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE orgs (id INTEGER PRIMARY KEY); + CREATE TABLE memberships ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(org_id, user_id) + ); + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE + ); + INSERT INTO users(id, token_version) VALUES (1, 0); + INSERT INTO orgs(id) VALUES (10); + INSERT INTO memberships(id, org_id, user_id) VALUES (100, 10, 1); + INSERT INTO projects(id, org_id) VALUES (1000, 10); + `); + installCalendarSubscriptionSchema(db); +} + +function deterministicRandomSource() { + let call = 0; + return { + randomBytes(length) { + call += 1; + return new Uint8Array(length).fill(call); + }, + }; +} + +test('high-frequency calendar polling retains bounded recent usage evidence without amplifying lifecycle outbox', async () => { + const db = new DatabaseSync(':memory:'); + installFixture(db); + const clock = { + value: 1_000_000, + nowMs() { + return this.value; + }, + }; + const repository = createSqliteCalendarSubscriptionRepository(db, { usageEventLimit: 2 }); + const service = createCalendarSubscriptionService({ + repository, + clock, + randomSource: deterministicRandomSource(), + auditSink: { record: async () => {} }, + projectAuthorization: createSqliteCalendarSubscriptionAuthorizationPort(db), + membershipRevocation: createSqliteCalendarSubscriptionMembershipPort(db), + }); + + const created = await service.create({ + subjectId: '1', + projectId: '1000', + name: 'Frequently polled feed', + expiresAtMs: 2_000_000, + }); + + for (const nowMs of [1_000_001, 1_000_002, 1_000_003]) { + clock.value = nowMs; + await service.authorize({ secret: created.secret, projectId: '1000' }); + } + + const usageEvents = db + .prepare('SELECT used_at_ms FROM subscription_usage_events ORDER BY usage_event_id') + .all() + .map(({ used_at_ms }) => used_at_ms); + assert.deepEqual( + usageEvents, + [1_000_002, 1_000_003], + 'only the configured recent-use window is retained for a repeatedly polled feed', + ); + assert.equal( + db.prepare('SELECT last_used_at_ms FROM calendar_subscriptions WHERE subscription_id = ?') + .get(created.subscriptionId).last_used_at_ms, + 1_000_003, + 'current lifecycle state preserves the exact last-use timestamp independently of history pruning', + ); + const outboxTypes = db + .prepare('SELECT event_type FROM calendar_subscription_audit_outbox ORDER BY audit_event_id') + .all() + .map(({ event_type }) => event_type); + assert.deepEqual( + outboxTypes, + ['created'], + 'read polling must not create an undelivered lifecycle-outbox row on every authorization', + ); + db.close(); +}); From 36d854d32ff1918c3fc524528ccbf87677d6ee14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 15:52:29 -0700 Subject: [PATCH 32/33] fix(calendar): bound subscription usage evidence --- server/calendar_subscription_sqlite.mjs | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/server/calendar_subscription_sqlite.mjs b/server/calendar_subscription_sqlite.mjs index dbc85ff3..769db90e 100644 --- a/server/calendar_subscription_sqlite.mjs +++ b/server/calendar_subscription_sqlite.mjs @@ -4,6 +4,7 @@ const ROTATE_SAVEPOINT = 'calendar_subscription_rotate_state'; const REVOKE_SAVEPOINT = 'calendar_subscription_revoke_state'; const CALENDAR_AUDIENCE = 'scopeweave:calendar'; const CALENDAR_PURPOSE = 'calendar_read'; +const DEFAULT_USAGE_EVENT_LIMIT = 256; function requireDatabase(database) { if (!database || typeof database.exec !== 'function' || typeof database.prepare !== 'function') { @@ -98,10 +99,12 @@ function resolveCalendarPurpose(value) { * * The authorization relation stores only the currently active SHA-256 secret * hash plus the fixed `calendar_read` purpose and the membership/session epoch - * captured when the credential was issued or rotated. Rotation and usage - * relations contain lifecycle facts only and never retain plaintext credentials - * or historical hashes. The audit outbox intentionally has no foreign key to - * the live subscription so security-event evidence survives resource deletion. + * captured when the credential was issued or rotated. Rotation and bounded + * usage relations contain lifecycle facts only and never retain plaintext + * credentials or historical hashes. The audit outbox intentionally has no + * foreign key to the live subscription so lifecycle security-event evidence + * survives resource deletion; high-frequency read usage is retained in the + * bounded usage relation instead of accumulating durable outbox rows. * * Call this during database bootstrap with foreign-key enforcement enabled. * Request handlers must never perform schema installation. @@ -179,16 +182,27 @@ export function installCalendarSubscriptionSchema(database) { * at the same persistence boundary. Rotation is the only path that can bind the * credential to a newly authorized membership epoch. * + * High-frequency successful reads keep `last_used_at_ms` plus only the most + * recent configured usage-event window. A transient `used` outbox insert remains + * inside the same savepoint as the usage transition so an outbox write failure + * still rolls back authorization evidence, but those read-only outbox rows are + * pruned before commit; durable outbox backlog is reserved for lifecycle events. + * * Management list and revoke queries independently require live project * membership, so authorization loss between domain preflight and persistence * cannot disclose metadata or mutate state. Rotation replaces the sole current * hash; no historical credential hash is copied into history relations. * * @param {object} database Node SQLite-compatible database handle. + * @param {{usageEventLimit?: number}} [options] Bounded per-subscription recent-use retention. * @returns {{insertSubscription: Function, listSubscriptions: Function, findSubscriptionByHash: Function, recordUsageAtomically: Function, rotateSubscriptionAtomically: Function, revokeSubscriptionAtomically: Function}} Repository adapter. */ -export function createSqliteCalendarSubscriptionRepository(database) { +export function createSqliteCalendarSubscriptionRepository(database, options = {}) { const db = requireDatabase(database); + const usageEventLimit = Math.max( + 1, + Math.min(10_000, Math.trunc(Number(options.usageEventLimit) || DEFAULT_USAGE_EVENT_LIMIT)), + ); const liveMembershipVersion = membershipVersionStatement(db); const insertSubscription = db.prepare(` INSERT INTO calendar_subscriptions( @@ -302,11 +316,26 @@ export function createSqliteCalendarSubscriptionRepository(database) { INSERT INTO subscription_usage_events(subscription_id, used_at_ms) VALUES(?,?) `); + const pruneUsage = db.prepare(` + DELETE FROM subscription_usage_events + WHERE subscription_id = ? + AND usage_event_id NOT IN ( + SELECT usage_event_id + FROM subscription_usage_events + WHERE subscription_id = ? + ORDER BY usage_event_id DESC + LIMIT ? + ) + `); const insertAudit = db.prepare(` INSERT INTO calendar_subscription_audit_outbox( subscription_id, event_type, subject_id, project_id, occurred_at_ms, delivered_at_ms ) VALUES(?,?,?,?,?,NULL) `); + const pruneUsageAudit = db.prepare(` + DELETE FROM calendar_subscription_audit_outbox + WHERE subscription_id = ? AND event_type = 'used' + `); return Object.freeze({ /** @@ -381,6 +410,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { if (Number(result.changes) !== 1) return null; const current = findByHash.get(secretHash); insertUsage.run(current.subscription_id, binding.now_ms); + pruneUsage.run(current.subscription_id, current.subscription_id, usageEventLimit); insertAudit.run( current.subscription_id, 'used', @@ -388,6 +418,7 @@ export function createSqliteCalendarSubscriptionRepository(database) { current.project_id, binding.now_ms, ); + pruneUsageAudit.run(current.subscription_id); return normalizeSubscriptionRow(current); }); }, From 1e491bac2fc42b1f17c93666223ce1da78c6ba61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 15:58:41 -0700 Subject: [PATCH 33/33] docs(calendar): align bounded usage evidence contract --- .../doctoring/calendar-subscription-sqlite.md | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/doctoring/calendar-subscription-sqlite.md b/docs/doctoring/calendar-subscription-sqlite.md index 35e00fc3..57066c51 100644 --- a/docs/doctoring/calendar-subscription-sqlite.md +++ b/docs/doctoring/calendar-subscription-sqlite.md @@ -8,14 +8,14 @@ The protected calendar feed still depends on a broad session credential transported in a URL. Parent PR #539 defines a separately revocable, project-bound reusable credential lifecycle and binds use to the membership epoch captured when the secret was issued, but intentionally contains no production persistence. PR #541 supplies the durable SQLite adapter needed to make that lifecycle survivable across process restarts while enforcing tenant/session revocation in 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 issue #413 slices and must not be represented as shipped by this PR. +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 lifecycle evidence that remains bounded under high-frequency feed polling. Route migration and customer-facing management UI remain later issue #413 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; +- `createSqliteCalendarSubscriptionRepository(database, options)` implements the parent-domain repository port and accepts an optional bounded `usageEventLimit`; - `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. @@ -70,7 +70,7 @@ erDiagram } ``` -`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 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. +`calendar_subscriptions` is the current authorization relation. It contains one current hash and current lifecycle state only. `subscription_rotations` contains repeating lifecycle facts, while `subscription_usage_events` retains only the configured most-recent usage window; `last_used_at_ms` remains the authoritative current-use timestamp after history pruning. The audit outbox is a secret-free lifecycle-event ledger/delivery relation rather than a per-poll usage log: create, rotate, and first revoke remain durably queued, while transient `used` rows are pruned before the usage savepoint commits. `subject_id` and `project_id` are event attributes captured at occurrence time so retained lifecycle 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 executes `PRAGMA foreign_key_check` and locks exact owned-object names to prevent silent naming/normalization drift. @@ -80,7 +80,7 @@ All owned table/index names contain multiple lexical words and use snake_case. T 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` and purpose is frozen to `calendar_read`. Current #539 sends the purpose explicitly. If a predecessor/older caller omits it, the adapter supplies only `calendar_read`; an explicit non-calendar purpose is preserved for the database CHECK/authorization boundary to reject. 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, purpose, audience, non-revocation, pre-expiry time, stored issuance membership epoch, and independently resolved live membership/session version before it records `last_used_at_ms` and usage evidence. +5. Use performs a conditional update that simultaneously verifies current hash, project, purpose, audience, non-revocation, pre-expiry time, stored issuance membership epoch, and independently resolved live membership/session version before it records `last_used_at_ms` and bounded 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; `revocation_applied` is true only for the first transition. @@ -92,9 +92,9 @@ This credential is ScopeWeave-specific and must **not** be represented as an OAu 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 executable failure-mode evidence for the “state and durable evidence together” contract rather than an assertion-only transaction test. +Create, rotate, and first revoke write durable lifecycle state plus `calendar_subscription_audit_outbox` evidence inside the same savepoint. Successful use updates `last_used_at_ms`, appends then prunes bounded `subscription_usage_events`, performs the existing transactional outbox write check, and prunes read-only `used` outbox rows before commit so calendar polling cannot create an unbounded delivery backlog. A test-installed trigger forces the transient outbox write to fail during use and verifies that `last_used_at_ms` and `subscription_usage_events` both roll back. This preserves executable failure-mode evidence for the “state and evidence together” transaction contract while keeping durable outbox backlog lifecycle-only. -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. +Outbox delivery itself is intentionally outside this slice. A later worker may mark lifecycle rows `delivered_at_ms`; deterministic authorization never depends on model judgement or outbox-delivery availability. ## TDD and acceptance evidence @@ -115,11 +115,12 @@ Focused acceptance coverage includes: - first-transition-only revocation evidence and idempotent repeated revoke; - cross-tenant management nondisclosure; - file-backed reopen/process-survival behavior; -- transactional rollback when durable audit evidence cannot be written; +- transactional rollback when the usage transition cannot write its outbox evidence; +- bounded recent-use retention under repeated polling with no durable `used` outbox backlog; - schema naming, normalized history relations, and foreign-key integrity; - race coverage for membership/rotation/use transitions. -The canonical unit and c8 commands include `server/calendar_subscription_sqlite.mjs` plus the persistence, race, and issuance-epoch suites. Exact 100% statement/branch/function/line evidence remains mandatory before this PR can be considered integration-ready; an ordinary unit-test GREEN run does not substitute for that measurement. +The canonical unit and c8 commands include `server/calendar_subscription_sqlite.mjs` plus the persistence, expiry, retention, race, and issuance-epoch suites. Exact 100% statement/branch/function/line evidence remains mandatory before this PR can be considered integration-ready; an ordinary unit-test GREEN run does not substitute for that measurement. ## Traceability @@ -131,11 +132,12 @@ The canonical unit and c8 commands include `server/calendar_subscription_sqlite. | Membership removal/re-add does not revive old credential | membership-row replacement regression | opaque `membership_id:token_version` | Active PR #541 | | Wrong or broader purpose cannot authorize | explicit wrong-purpose regressions + SQL CHECK | `purpose = calendar_read` | Active PR #541 | | Cross-tenant project existence is not disclosed | other-tenant list/rotate regression | authorization port + parent domain mapping | Active PR #541 | -| State and audit evidence cannot diverge on write failure | forced-outbox-failure rollback regression | savepoint transaction | Active PR #541 | +| State and usage evidence roll back together on write failure | forced-outbox-failure rollback regression | usage savepoint transaction | Active PR #541 | +| Frequent polling does not create unbounded durable evidence | bounded-retention regression | `usageEventLimit` + usage/outbox pruning | Active PR #541 | | Durable credential survives process restart | file-backed SQLite reopen regression | SQLite repository | Active PR #541 | | History contains no credential material | schema introspection assertions | rotation/usage/audit relations | Active PR #541 | | DB object naming and referential integrity | exact object list + `foreign_key_check` | schema installer | Active PR #541 | -| Exact-head CI evidence | current contributor SHA and exact current base required | repository/organization workflows | Regenerating after retarget; predecessor evidence non-passing | +| Exact-head CI evidence | current contributor SHA and exact current base required | repository/organization workflows | Regenerating after current-head mutation; predecessor evidence non-passing | | Independent current-head approval | qualifying independent reviewer after latest push | protected branch/ruleset governance | Required before integration | | 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 issue #413 interaction contract | browser client | Planned / out of this slice |