Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
df122c3
test(schedule): require project-version reason-event adapter
seonghobae Aug 17, 2026
6c7b37d
test(schedule): register project-version adapter evidence
seonghobae Aug 17, 2026
a11c99d
test(schedule): lock project-version coverage wiring
seonghobae Aug 17, 2026
58545c4
merge(schedule): reconcile project-version RED with repaired persiste…
seonghobae Aug 17, 2026
ceca631
feat(schedule): advance authoritative project versions for reason events
seonghobae Aug 17, 2026
71ea1b0
style(schedule): preserve canonical package layout
seonghobae Aug 17, 2026
49b0040
style(schedule): preserve coverage contract layout
seonghobae Aug 17, 2026
13c8826
docs(schedule): record authoritative project-version boundary
seonghobae Aug 17, 2026
684660b
docs(schedule): record authoritative project-version adapter
seonghobae Aug 17, 2026
43f6855
test(schedule): prove project version rolls back with audit failure
seonghobae Aug 17, 2026
63b67b1
test(schedule): reject blank work-item authority
seonghobae Aug 17, 2026
dc8d1b8
fix(schedule): reject blank work-item authority
seonghobae Aug 17, 2026
72a0ea2
docs(schedule): record blank work-item hardening
seonghobae Aug 17, 2026
68738a7
docs(changelog): record reason-event boundary hardening
seonghobae Aug 17, 2026
8f466dd
merge(schedule): reconcile project-version adapter with persistence p…
seonghobae Aug 17, 2026
cbb3806
fix(stack): inherit current reason persistence parent
seonghobae Aug 20, 2026
15d0923
fix(stack): reconcile project-version binding with current persistenc…
seonghobae Aug 24, 2026
22883d1
fix(stack): reconcile project-version binding with latest persistence…
seonghobae Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions docs/doctoring/schedule-reason-project-version.md
Original file line number Diff line number Diff line change
@@ -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:<positive-safe-integer>` 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
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && 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",
Expand Down
153 changes: 153 additions & 0 deletions server/schedule_reason_event_project_version.mjs
Original file line number Diff line number Diff line change
@@ -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:<integer>` 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;
}
Comment on lines +60 to +80

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: Membership scan only checks top-level tasks

countWorkItemIdentity (server/schedule_reason_event_project_version.mjs:60-80) inspects only top-level entries of the parsed tasks_json array. This matches today's flat tasks array persisted in server/app.mjs:272-276. A future move to nested work items would make valid IDs unfindable, silently failing transitions closed.

Open in Devin Review

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


/**
* 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),
});
},
});
}
Loading