diff --git a/CHANGELOG.md b/CHANGELOG.md index 8185d27c..be592e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a typed same-connection project-version transition adapter that binds + terminal reason events to the existing `projects.version` authority and exact + work-item membership without rewriting `tasks_json` or creating a parallel + work-item version store. - Added normalized SQLite persistence for authorized terminal schedule reason events, verified cancellation approval evidence, immutable audit records, and same-savepoint resource-version transition/rollback without duplicating the @@ -34,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Rejected whitespace-only work-item authority at the schedule reason-event + project-version persistence boundary so direct adapter use cannot bypass the + domain layer's non-blank identity contract before an authoritative version + transition. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Added a version-bound schedule reason-event authorization and audit contract for skipped, cancelled, and not-performed decisions; cancellation approval diff --git a/docs/doctoring/schedule-reason-project-version.md b/docs/doctoring/schedule-reason-project-version.md new file mode 100644 index 00000000..8887ef58 --- /dev/null +++ b/docs/doctoring/schedule-reason-project-version.md @@ -0,0 +1,45 @@ +# Schedule reason-event authoritative project-version boundary + +## Status and scope + +This document describes **active stacked pull-request work**, not protected-`develop` shipped truth. The slice is stacked on PR #519 exact current head `3922071464c0feb9f643ce99dd0d7a12be5886fa` and connects that reason-event repository port to ScopeWeave's existing authoritative project-plan concurrency field, `projects.version`, without introducing a parallel work-item version table. + +ScopeWeave currently persists each project plan in `projects.tasks_json` and uses the project row's integer `version` for optimistic concurrency. A terminal reason event refers to one work-item ID inside that exact plan snapshot. The adapter therefore verifies tenant, project, work-item membership, and expected project version before conditionally advancing the project row. It does not rewrite task JSON; reason facts remain in the normalized relations owned by PR #519. + +## Authority and concurrency contract + +`server/schedule_reason_event_project_version.mjs` exposes a typed resource token `project_version:` so database version authority is not confused with unrelated resource-version families. External organization/project identities must be canonical positive decimal strings; lossy alternatives such as leading-zero, signed, whitespace-padded, fractional, or scientific notation fail closed before database mutation. Work-item IDs must be bounded, non-blank text without control characters; whitespace-only authority therefore fails closed even if the adapter is invoked below the normal domain-validation boundary. + +A transition succeeds only when all of the following remain true on the same SQLite connection: + +- the exact `projects.id` and `projects.org_id` row exists; +- the stored project version equals the authorization-bound expected version; +- the exact non-blank work-item ID exists exactly once in the current `tasks_json` array; and +- the current version can advance without exceeding JavaScript's safe-integer range. + +The conditional `UPDATE` increments only the project version and timestamp. Stale version, wrong tenant/project, missing work-item, or an update race returns a non-advanced result. Blank or control-character work-item authority, malformed task JSON, duplicate work-item identity, malformed authority tokens, or unsafe stored versions throw stable fail-closed errors. When invoked from the PR #519 repository inside its savepoint, a later event/audit failure rolls the project-version update back with the normalized event records. + +## TDD and executable evidence + +- The initial RED contract was captured on `a11c99db7fe379a60fc01702bbca8bf45a1915a4`; repository-native `unit-and-api` failed with `ERR_MODULE_NOT_FOUND` for `server/schedule_reason_event_project_version.mjs`. +- The RED branch was reconciled non-destructively with repaired persistence parent `5103c79109d08553d6fe5c679cdf0a16fa989609` before production implementation; the current stack has since been reconciled again to PR #519 head `3922071464c0feb9f643ce99dd0d7a12be5886fa`. +- The contract covers exact successful advancement without task-data rewriting; stale, cross-tenant, cross-project, and missing-work-item failures; malformed/ambiguous numeric identities and version tokens; malformed or duplicate task snapshots; predecessor-version replay; and the `Number.MAX_SAFE_INTEGER` non-advancement boundary. +- Defense-in-depth RED `63b67b17355c5beae41b5c14b83d214b12691a62` added a whitespace-only work-item authority case. Hosted `unit-and-api` failed on that exact head while `cloud-e2e` remained green, proving the persistence adapter accepted an invalid boundary shape below the domain layer. +- GREEN `dc8d1b88af48b8f00d4bf4412ed79cb6d9e8c23a` rejects blank work-item authority before database lookup; hosted `unit-and-api` passed on that exact head. +- Fresh hosted GREEN evidence on the final exact head remains authoritative; predecessor or RED-head checks do not transfer. + +## Security, privacy, and rollback + +The adapter does not derive authorization from browser input and does not expose tenant-existence details beyond the repository's stable advanced/non-advanced contract. It stores no secret or additional PII. It preserves the existing project/task persistence model and creates no schema object. Rollback before integration removes this adapter, its focused tests/coverage wiring, this record, and its Unreleased CHANGELOG entry. Durable reason-event rows remain owned by the parent persistence slice. + +## Standards boundary + +SQLite's transactional/savepoint semantics and conditional-update behavior are the operative technical basis for same-connection atomicity. NIST SP 800-53 Rev. 5 AC-3 and AU-family controls remain control-design context for access enforcement and auditable state change; this implementation does not claim certification or that NIST prescribes ScopeWeave's version-token format. + +## References + +National Institute of Standards and Technology. (2025). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5, Release 5.2.0). https://doi.org/10.6028/NIST.SP.800-53r5 + +SQLite Consortium. (n.d.). *Atomic commit in SQLite*. SQLite. https://www.sqlite.org/atomiccommit.html + +SQLite Consortium. (n.d.). *Savepoints*. SQLite. https://www.sqlite.org/lang_savepoint.html diff --git a/package.json b/package.json index 779533c5..f3f5fffd 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/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.test.mjs && node tests/unit/schedule-outcome-domain-edge.test.mjs && node tests/unit/schedule-reason-event-domain.test.mjs && node tests/unit/schedule-reason-self-approval.test.mjs && node tests/unit/schedule-reason-event-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/work_item_hierarchy.mjs --include=server/schedule_outcome_domain.mjs --include=server/schedule_reason_event_domain.mjs --include=server/schedule_reason_event_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/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/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.test.mjs && node tests/unit/schedule-outcome-domain-edge.test.mjs && node tests/unit/schedule-reason-event-domain.test.mjs && node tests/unit/schedule-reason-self-approval.test.mjs && node tests/unit/schedule-reason-event-sqlite.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/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/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.test.mjs && node tests/unit/schedule-outcome-domain-edge.test.mjs && node tests/unit/schedule-reason-event-domain.test.mjs && node tests/unit/schedule-reason-self-approval.test.mjs && node tests/unit/schedule-reason-event-sqlite.test.mjs && node tests/unit/schedule-reason-project-version.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/work_item_hierarchy.mjs --include=server/schedule_outcome_domain.mjs --include=server/schedule_reason_event_domain.mjs --include=server/schedule_reason_event_sqlite.mjs --include=server/schedule_reason_event_project_version.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/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/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.test.mjs && node tests/unit/schedule-outcome-domain-edge.test.mjs && node tests/unit/schedule-reason-event-domain.test.mjs && node tests/unit/schedule-reason-self-approval.test.mjs && node tests/unit/schedule-reason-event-sqlite.test.mjs && node tests/unit/schedule-reason-project-version.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/server/schedule_reason_event_project_version.mjs b/server/schedule_reason_event_project_version.mjs new file mode 100644 index 00000000..b19f5d69 --- /dev/null +++ b/server/schedule_reason_event_project_version.mjs @@ -0,0 +1,153 @@ +const RESOURCE_VERSION_PREFIX = 'project_version:'; +const CANONICAL_POSITIVE_INTEGER = /^[1-9][0-9]*$/u; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u; +const MAX_WORK_ITEM_ID_LENGTH = 256; +const NOT_ADVANCED = Object.freeze({ advanced: false }); + +/** + * Format one authoritative ScopeWeave project version for reason-event ports. + * + * The token is deliberately typed rather than exposing a bare database integer, + * so a project-level concurrency value cannot be confused with another resource + * version family. + * + * @param {unknown} projectVersion positive safe integer stored by `projects.version`. + * @returns {string} canonical `project_version:` token. + * @throws {TypeError} when the version cannot be represented without ambiguity. + */ +export function formatScheduleReasonResourceVersion(projectVersion) { + if (!Number.isSafeInteger(projectVersion) || projectVersion < 1) { + throw new TypeError('project version must be a positive safe integer'); + } + return `${RESOURCE_VERSION_PREFIX}${projectVersion}`; +} + +/** Parse an external database identity without accepting lossy numeric syntax. */ +function parseCanonicalPositiveInteger(value, field) { + if (typeof value !== 'string' || !CANONICAL_POSITIVE_INTEGER.test(value)) { + throw new TypeError(`${field} must be a canonical positive integer string`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== value) { + throw new TypeError(`${field} must be a canonical positive safe integer string`); + } + return parsed; +} + +/** Parse one project resource-version token back to its database integer. */ +function parseResourceVersion(value) { + if (typeof value !== 'string' || !value.startsWith(RESOURCE_VERSION_PREFIX)) { + throw new TypeError('expectedResourceVersion must be a canonical project-version token'); + } + const versionText = value.slice(RESOURCE_VERSION_PREFIX.length); + return parseCanonicalPositiveInteger(versionText, 'expectedResourceVersion'); +} + +/** Require an exact bounded work-item identifier without control characters. */ +function requireWorkItemId(value) { + if ( + typeof value !== 'string' + || value.trim().length === 0 + || value.length > MAX_WORK_ITEM_ID_LENGTH + || CONTROL_CHARACTERS.test(value) + ) { + throw new TypeError('workItemId must be bounded non-blank text without control characters'); + } + return value; +} + +/** Parse the authoritative project task snapshot and count an exact work-item ID. */ +function countWorkItemIdentity(tasksJson, workItemId) { + let tasks; + try { + tasks = JSON.parse(tasksJson); + } catch { + throw new Error('project tasks_json is invalid'); + } + if (!Array.isArray(tasks)) { + throw new Error('project tasks_json is invalid'); + } + let count = 0; + for (const task of tasks) { + if (task && typeof task === 'object' && !Array.isArray(task) && task.id === workItemId) { + count += 1; + if (count > 1) { + throw new Error('project tasks_json contains duplicate work-item identity'); + } + } + } + return count; +} + +/** + * Create the authoritative SQLite project-version transition adapter used by + * `createSqliteScheduleReasonEventRepository`. + * + * ScopeWeave currently stores a project plan atomically in `projects.tasks_json` + * and protects that plan with `projects.version`. The adapter therefore verifies + * the exact tenant, project, work-item membership, and expected project version, + * then advances only `projects.version` with one conditional UPDATE on the same + * synchronous SQLite connection. It never rewrites task JSON or creates a second + * work-item/version table. When called inside the reason-event repository + * savepoint, the version transition rolls back together with event/audit writes. + * + * @param {object} database Node SQLite-compatible handle exposing `prepare()`. + * @returns {Readonly<{advanceResourceVersion: Function}>} immutable transition port. + */ +export function createSqliteScheduleReasonProjectVersionAdapter(database) { + if (!database || typeof database.prepare !== 'function') { + throw new TypeError('schedule reason project-version adapter requires database.prepare()'); + } + + const selectProject = database.prepare(` + SELECT tasks_json, version + FROM projects + WHERE id = ? AND org_id = ? + `); + const advanceProject = database.prepare(` + UPDATE projects + SET version = version + 1, + updated_at = datetime('now') + WHERE id = ? + AND org_id = ? + AND version = ? + `); + + return Object.freeze({ + /** + * Advance the exact project-plan version only while the authorized target + * work item still exists exactly once in that version's task snapshot. + * + * @param {unknown} binding exact tenant/project/work-item/version binding. + * @returns {Readonly<{advanced: boolean, resourceVersion?: string}>} transition result. + */ + advanceResourceVersion(binding) { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + throw new TypeError('project-version transition binding must be an object'); + } + const organizationId = parseCanonicalPositiveInteger(binding.organizationId, 'organizationId'); + const projectId = parseCanonicalPositiveInteger(binding.projectId, 'projectId'); + const workItemId = requireWorkItemId(binding.workItemId); + const expectedVersion = parseResourceVersion(binding.expectedResourceVersion); + + const row = selectProject.get(projectId, organizationId); + if (!row) return NOT_ADVANCED; + if (!Number.isSafeInteger(row.version) || row.version < 1) { + throw new Error('stored project version is invalid'); + } + if (row.version !== expectedVersion) return NOT_ADVANCED; + if (countWorkItemIdentity(row.tasks_json, workItemId) === 0) return NOT_ADVANCED; + if (expectedVersion === Number.MAX_SAFE_INTEGER) { + throw new Error('project version cannot advance beyond the safe integer range'); + } + + const update = advanceProject.run(projectId, organizationId, expectedVersion); + if (Number(update.changes) !== 1) return NOT_ADVANCED; + + return Object.freeze({ + advanced: true, + resourceVersion: formatScheduleReasonResourceVersion(expectedVersion + 1), + }); + }, + }); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 17a80808..0626f7a9 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -54,6 +54,11 @@ assert.match( /--include=server\/schedule_reason_event_sqlite\.mjs/, 'the durable schedule reason-event adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/schedule_reason_event_project_version\.mjs/, + 'the authoritative project-version adapter is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -89,6 +94,11 @@ assert.match( /tests\/unit\/schedule-reason-event-sqlite\.test\.mjs/, 'the durable schedule reason-event transaction contract executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/schedule-reason-project-version\.test\.mjs/, + 'the authoritative project-version transition contract executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/schedule-reason-project-version.test.mjs b/tests/unit/schedule-reason-project-version.test.mjs new file mode 100644 index 00000000..998f979d --- /dev/null +++ b/tests/unit/schedule-reason-project-version.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; + +import { + createSqliteScheduleReasonProjectVersionAdapter, + formatScheduleReasonResourceVersion, +} from '../../server/schedule_reason_event_project_version.mjs'; +import { + createSqliteScheduleReasonEventRepository, + installScheduleReasonEventSchema, +} from '../../server/schedule_reason_event_sqlite.mjs'; + +function createDatabase({ + projectId = 41, + organizationId = 7, + version = 3, + tasks = [{ id: 'work-item-01', task: 'Prepare cutover' }], +} = {}) { + const database = new DatabaseSync(':memory:'); + database.exec(` + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + tasks_json TEXT NOT NULL DEFAULT '[]', + version INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + database.prepare(` + INSERT INTO projects(id, org_id, tasks_json, version) + VALUES(?, ?, ?, ?) + `).run(projectId, organizationId, JSON.stringify(tasks), version); + return database; +} + +function request(overrides = {}) { + return { + organizationId: '7', + projectId: '41', + workItemId: 'work-item-01', + expectedResourceVersion: 'project_version:3', + ...overrides, + }; +} + +function reasonEvent(overrides = {}) { + return { + eventId: 'evt-PROJECT-VERSION-01', + contractVersion: 'schedule-reason-event/v1', + organizationId: '7', + projectId: '41', + workItemId: 'work-item-01', + expectedWorkItemVersion: 'project_version:3', + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-owner-9', + occurredAt: '2026-08-17T12:00:00.000Z', + observedAt: '2026-08-17T13:00:00.000Z', + authorizationId: 'authz-decision-22', + approval: null, + ...overrides, + }; +} + +test('resource versions are canonical project-version tokens', () => { + assert.equal(formatScheduleReasonResourceVersion(1), 'project_version:1'); + assert.equal(formatScheduleReasonResourceVersion(Number.MAX_SAFE_INTEGER), `project_version:${Number.MAX_SAFE_INTEGER}`); + for (const invalid of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '1', null]) { + assert.throws(() => formatScheduleReasonResourceVersion(invalid), /project version must be a positive safe integer/); + } +}); + +test('exact tenant, project, work item, and version advance atomically without rewriting task data', () => { + const database = createDatabase(); + const originalTasks = database.prepare('SELECT tasks_json FROM projects WHERE id = 41').get().tasks_json; + const adapter = createSqliteScheduleReasonProjectVersionAdapter(database); + const result = adapter.advanceResourceVersion(request()); + assert.deepEqual(result, { advanced: true, resourceVersion: 'project_version:4' }); + assert.equal(Object.isFrozen(result), true); + const row = database.prepare('SELECT org_id, tasks_json, version FROM projects WHERE id = 41').get(); + assert.equal(row.org_id, 7); + assert.equal(row.version, 4); + assert.equal(row.tasks_json, originalTasks); + database.close(); +}); + +test('stale, cross-tenant, cross-project, and unknown-work-item transitions fail closed', () => { + const cases = [ + request({ expectedResourceVersion: 'project_version:2' }), + request({ organizationId: '8' }), + request({ projectId: '42' }), + request({ workItemId: 'work-item-missing' }), + ]; + for (const candidate of cases) { + const database = createDatabase(); + const adapter = createSqliteScheduleReasonProjectVersionAdapter(database); + assert.deepEqual(adapter.advanceResourceVersion(candidate), { advanced: false }); + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, 3); + database.close(); + } +}); + +test('adapter rejects ambiguous database identities and malformed version authority before mutation', () => { + const database = createDatabase(); + const adapter = createSqliteScheduleReasonProjectVersionAdapter(database); + const invalidRequests = [ + request({ organizationId: '07' }), + request({ organizationId: '+7' }), + request({ organizationId: ' 7' }), + request({ projectId: '041' }), + request({ projectId: '4.1e1' }), + request({ expectedResourceVersion: 'project_version:03' }), + request({ expectedResourceVersion: '3' }), + request({ expectedResourceVersion: 'project_version:0' }), + request({ workItemId: '' }), + request({ workItemId: ' ' }), + request({ workItemId: 'x'.repeat(257) }), + ]; + for (const candidate of invalidRequests) { + assert.throws(() => adapter.advanceResourceVersion(candidate)); + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, 3); + } + database.close(); +}); + +test('malformed or ambiguous task containers fail closed without advancing authority', () => { + const malformedDatabase = createDatabase(); + malformedDatabase.prepare('UPDATE projects SET tasks_json = ? WHERE id = 41').run('{bad-json'); + const malformedAdapter = createSqliteScheduleReasonProjectVersionAdapter(malformedDatabase); + assert.throws(() => malformedAdapter.advanceResourceVersion(request()), /project tasks_json is invalid/); + assert.equal(malformedDatabase.prepare('SELECT version FROM projects WHERE id = 41').get().version, 3); + malformedDatabase.close(); + + const duplicateDatabase = createDatabase({ tasks: [ + { id: 'work-item-01', task: 'First copy' }, + { id: 'work-item-01', task: 'Duplicate copy' }, + ] }); + const duplicateAdapter = createSqliteScheduleReasonProjectVersionAdapter(duplicateDatabase); + assert.throws(() => duplicateAdapter.advanceResourceVersion(request()), /project tasks_json contains duplicate work-item identity/); + assert.equal(duplicateDatabase.prepare('SELECT version FROM projects WHERE id = 41').get().version, 3); + duplicateDatabase.close(); +}); + +test('one successful transition makes the predecessor version unusable on the next attempt', () => { + const database = createDatabase(); + const adapter = createSqliteScheduleReasonProjectVersionAdapter(database); + assert.deepEqual(adapter.advanceResourceVersion(request()), { advanced: true, resourceVersion: 'project_version:4' }); + assert.deepEqual(adapter.advanceResourceVersion(request()), { advanced: false }); + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, 4); + database.close(); +}); + +test('maximum safe project version cannot be advanced into an unsafe integer', () => { + const database = createDatabase({ version: Number.MAX_SAFE_INTEGER }); + const adapter = createSqliteScheduleReasonProjectVersionAdapter(database); + assert.throws( + () => adapter.advanceResourceVersion(request({ expectedResourceVersion: `project_version:${Number.MAX_SAFE_INTEGER}` })), + /project version cannot advance beyond the safe integer range/, + ); + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, Number.MAX_SAFE_INTEGER); + database.close(); +}); + +test('real reason-event savepoint rolls the authoritative project version back when audit persistence fails', async () => { + const database = createDatabase(); + database.exec('PRAGMA foreign_keys = ON'); + installScheduleReasonEventSchema(database); + const versionAdapter = createSqliteScheduleReasonProjectVersionAdapter(database); + const repository = createSqliteScheduleReasonEventRepository(database, { + advanceResourceVersion: versionAdapter.advanceResourceVersion, + nextAuditRecordId: () => 'audit-project-version-fixed', + }); + + await repository.commitReasonEvent({ + event: reasonEvent(), + expectedResourceVersion: 'project_version:3', + }); + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, 4); + + database.prepare('UPDATE projects SET version = 3 WHERE id = 41').run(); + await assert.rejects( + repository.commitReasonEvent({ + event: reasonEvent({ eventId: 'evt-PROJECT-VERSION-02' }), + expectedResourceVersion: 'project_version:3', + }), + /UNIQUE constraint failed: schedule_reason_event_audit_records.audit_record_id/, + ); + + assert.equal(database.prepare('SELECT version FROM projects WHERE id = 41').get().version, 3); + assert.equal(database.prepare('SELECT COUNT(*) AS count FROM schedule_reason_events').get().count, 1); + assert.equal(database.prepare('SELECT COUNT(*) AS count FROM schedule_reason_event_audit_records').get().count, 1); + database.close(); +});