Skip to content

fix(calendar): bind omitted purpose to calendar_read - #541

Open
cursor[bot] wants to merge 38 commits into
cursor/bc-4522f1d4-ae7e-434e-b612-d250afa4d097-5645from
cursor/bc-f7050a51-e6e0-448d-bbfa-39c45a93d707-0fd7
Open

fix(calendar): bind omitted purpose to calendar_read#541
cursor[bot] wants to merge 38 commits into
cursor/bc-4522f1d4-ae7e-434e-b612-d250afa4d097-5645from
cursor/bc-f7050a51-e6e0-448d-bbfa-39c45a93d707-0fd7

Conversation

@cursor

@cursor cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Buyer/security outcome

Refs #413. This stacked persistence slice makes reusable calendar subscriptions durable without storing plaintext credentials. It preserves project/purpose/audience binding, issuance membership/session epoch revocation, first-transition-only revoke evidence, and now bounds high-frequency usage evidence so calendar polling cannot grow durable history/outbox state without limit.

This PR does not change the protected calendar HTTP route or customer UI. Runtime composition remains in child #549.

Exact current stack

Fresh state after the latest repair:

  • protected develop: 2c328875e00e86537df3e965170be80532571cad;
  • exact parent/base fix(calendar): bind subscription use to issuance membership epoch #539: cursor/bc-4522f1d4-ae7e-434e-b612-d250afa4d097-5645@e2f560078d3e2862c5dc6c395c159d12dfe1e6fa;
  • exact fix(calendar): bind omitted purpose to calendar_read #541 head: cursor/bc-f7050a51-e6e0-448d-bbfa-39c45a93d707-0fd7@1e491bac2fc42b1f17c93666223ce1da78c6ba61;
  • parent→child comparison is ahead, zero behind, with exact current parent as merge base;
  • effective child scope is 12 paths: ARCHITECTURE.md, CHANGELOG.md, docs/doctoring/calendar-subscription-sqlite.md, package.json, server/calendar_subscription_sqlite.mjs, tests/unit/calendar-subscription-sqlite-expiry.test.mjs, tests/unit/calendar-subscription-sqlite-indexes.test.mjs, tests/unit/calendar-subscription-sqlite-issuance-epoch.test.mjs, tests/unit/calendar-subscription-sqlite-race.test.mjs, tests/unit/calendar-subscription-sqlite-retention.test.mjs, tests/unit/calendar-subscription-sqlite.test.mjs, and tests/unit/coverage-script-contract.test.mjs;
  • downstream runtime feat(calendar): add durable subscription runtime #549 has been non-destructively reconciled onto this exact head and preserves its bounded ten-path child diff.

The PR is currently Ready rather than Draft, but it is not merge-authorized while prerequisites and exact-head governance evidence remain incomplete.

Persistence/security contract

  • plaintext subscription secrets exist only at the domain create/rotate return boundary; persistence receives only SHA-256 hashes;
  • stored purpose is frozen to calendar_read and audience to scopeweave:calendar; explicit broader purpose is rejected;
  • use requires the stored issuance membership_id:token_version epoch to equal the supplied and independently resolved live epoch;
  • remove/rejoin and session-wide token-version invalidation therefore revoke old reusable credentials;
  • authorized rotation re-binds the credential to the new live epoch while invalidating the previous hash, but an already expired subscription cannot be revived by rotation;
  • management remains tenant-nondisclosing and rechecks live membership at persistence transitions;
  • revoke is idempotent and records durable lifecycle evidence only for the first state transition;
  • create/rotate/revoke lifecycle events remain in the secret-free audit outbox;
  • successful use preserves exact last_used_at_ms plus only the configured recent usage-event window (256 by default, bounded to 1..10000);
  • the usage path retains the transactional outbox-write failure boundary, then prunes transient used outbox rows before commit, preventing high-frequency feed polling from producing an unbounded durable delivery backlog;
  • schema/object naming remains normalized and foreign-key policy remains an explicit bootstrap responsibility.

Test-first repair: bounded usage evidence

Current-head review identified that every successful authorization appended both a usage row and an audit-outbox row indefinitely. The registered regression tests/unit/calendar-subscription-sqlite-retention.test.mjs first failed on the predecessor implementation: three authorizations with usageEventLimit: 2 retained all three usage rows instead of only the two newest rows.

Production repair 36d854d32ff1918c3fc524528ccbf87677d6ee14 added bounded per-subscription usage retention and lifecycle-only durable outbox behavior while preserving the existing forced-outbox-failure rollback contract. 1e491bac2fc42b1f17c93666223ce1da78c6ba61 then aligned the doctoring/traceability record with that executable behavior. The validated unbounded-growth thread is resolved; currently open Devin threads are informational analyses rather than identified defects.

Current behavioral evidence

Current-head-associated runs on 1e491bac2fc42b1f17c93666223ce1da78c6ba61 are terminal success:

  • Server Tests 33124578344 — success;
  • Dependency Review 33124578345 — success;
  • OSV Scanner 33124578769 — success.

The registered unit suite includes the expiry, bounded-retention, index, race, issuance-epoch, restart/rollback, tenant-nondisclosure and coverage-contract scenarios. Exact 100% owned production statement/branch/function/line evidence remains a separate mandatory merge gate; ordinary unit-test success is not substituted for that measurement.

These GitHub Actions successes are behavioral evidence only under the repository's current evidence contract: pull-request workflows still check out GitHub's synthetic merge revision rather than the contributor head. ScopeWeave #523 owns the repository-native exact-head checkout repair, while organization-reusable SAST/Security exact-head integrity remains in the existing .github owner path. No predecessor, synthetic-only, status-only, model-only, skipped, pending or stale evidence is promoted to merge authority.

There is no qualifying independent current-head/last-push approval. The current requested reviewer is the repository owner and must not be treated as independent approval.

Merge gate

Do not integrate independently of #539/#506. Before merge, refetch the unchanged exact head and live base, preserve the current security semantics, require all then-applicable exact-contributor-head CI/browser E2E/owned coverage/docstrings/SAST/security/dependency/supply-chain/package/provenance/migration/recovery gates, zero valid unresolved defects, and qualifying independent current-head approval under live branch protection/rulesets.

Pending, queued, skipped-required, cancelled, absent, neutral-required, failed, stale, predecessor, synthetic-only, status-only, author-only, or model-only evidence is non-passing.

Documentation / rollback

docs/doctoring/calendar-subscription-sqlite.md records the normalized data model, credential and tenant invariants, bounded usage-evidence contract, transactional failure behavior, rollback/recovery and APA 7 references. Rollback before route integration removes this adapter/tests/docs/registrations together; after route integration, rollback must preserve lifecycle evidence and must not restore a broad session JWT in calendar URLs as a steady state.

seonghobae and others added 21 commits August 17, 2026 18:59
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 <seonghobae@users.noreply.github.com>
@cursor
cursor Bot force-pushed the cursor/bc-f7050a51-e6e0-448d-bbfa-39c45a93d707-0fd7 branch from 54af635 to e1390a3 Compare August 17, 2026 19:04
@opencode-agent opencode-agent Bot added priority: medium Normal-priority or P2 work status: draft Draft pull request type: bug Defect or incorrect behavior labels Aug 22, 2026
@seonghobae
seonghobae marked this pull request as ready for review August 26, 2026 22:57

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread server/calendar_subscription_sqlite.mjs Outdated
Comment thread server/calendar_subscription_sqlite.mjs Outdated
Comment thread server/calendar_subscription_sqlite.mjs
Comment on lines +112 to +113
export function installCalendarSubscriptionSchema(database) {
const db = requireDatabase(database);

@devin-ai-integration devin-ai-integration Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Schema installer intentionally unwired

installCalendarSubscriptionSchema is exported but never called from server/db.mjs or server/app.mjs. The doctoring record defers route/db integration to a later #413 slice, so this matches the stated scope rather than being a missed wiring step.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +234 to +257
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 purpose = ?
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)) = ?
)
`);

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Usage transition rejects rejoin and session revocation

recordUsage requires both the stored membership_version and a live membership_id:token_version join to equal the supplied issuance epoch. Remove-then-rejoin changes the membership id and session invalidation changes the token version, so either makes the transition match no row and return null before any usage or audit write.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/calendar_subscription_sqlite.mjs
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +258 to +280
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 purpose = ?
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)) = ?
)
`);

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: rotate UPDATE omits audience filter, still safe

The rotate statement replaceSecret filters on purpose but not audience, unlike the use path. Safe: the audience column is CHECK-pinned to a single value and the domain revalidates it after rotation.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +112 to +167
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 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),
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),
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 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);

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);
`);
}

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Foreign-key enforcement left to caller

The schema declares cascading foreign keys, but SQLite disables enforcement by default. installCalendarSubscriptionSchema does not enable it and documents this as a bootstrap responsibility. A production bootstrap that omits PRAGMA foreign_keys = ON silently loses cascade/integrity guarantees.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +234 to +257
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 purpose = ?
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)) = ?
)
`);

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Usage event still recorded when timestamp unchanged

When the clock has not advanced, the last_used_at_ms CASE keeps the old value, yet the update still reports one changed row because the row matched. A usage event and audit row are still written, which is the intended per-authorization semantics.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment thread server/calendar_subscription_sqlite.mjs
Comment on lines +401 to +437
async rotateSubscriptionAtomically(subscriptionId, binding) {
return withSavepoint(db, ROTATE_SAVEPOINT, () => {
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,
binding.expires_at_ms,
binding.now_ms,
subscriptionId,
binding.subject_id,
binding.project_id,
resolveCalendarPurpose(binding.purpose),
binding.now_ms,
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);
});
},

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Rotate rebinds epoch without matching stored value

rotateSubscriptionAtomically checks only that the supplied membership version matches live membership, not the stored membership_version. This is the sanctioned rebind-after-rejoin path, unlike recordUsage which requires stored, supplied, and live to all agree.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +30 to +46
} catch (error) {
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;
}

@devin-ai-integration devin-ai-integration Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Rollback-failure path intentionally leaves savepoint open

withSavepoint skips RELEASE when ROLLBACK TO fails, to avoid committing failed state, and rethrows the causal error. This matches the two race tests covering release/rollback cleanup failure. Intentional, not a defect.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +414 to +421
insertAudit.run(
current.subscription_id,
'used',
current.subject_id,
current.project_id,
binding.now_ms,
);
pruneUsageAudit.run(current.subscription_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: 'used' audit rows inserted then pruned before commit

recordUsageAtomically inserts a used outbox row then deletes all used rows for the subscription before release, so no read evidence survives commit. The insert exists only so a forced-outbox-failure rolls back authorization state; the domain emits the durable used audit through its own sink. Intentional per the doctoring record.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Normal-priority or P2 work status: draft Draft pull request type: bug Defect or incorrect behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants