-
Notifications
You must be signed in to change notification settings - Fork 0
feat(schedule): bind reason events to authoritative project versions #548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
seonghobae
wants to merge
18
commits into
feat/schedule-reason-event-sqlite-287
Choose a base branch
from
feat/schedule-reason-project-version-287
base: feat/schedule-reason-event-sqlite-287
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
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 6c7b37d
test(schedule): register project-version adapter evidence
seonghobae a11c99d
test(schedule): lock project-version coverage wiring
seonghobae 58545c4
merge(schedule): reconcile project-version RED with repaired persiste…
seonghobae ceca631
feat(schedule): advance authoritative project versions for reason events
seonghobae 71ea1b0
style(schedule): preserve canonical package layout
seonghobae 49b0040
style(schedule): preserve coverage contract layout
seonghobae 13c8826
docs(schedule): record authoritative project-version boundary
seonghobae 684660b
docs(schedule): record authoritative project-version adapter
seonghobae 43f6855
test(schedule): prove project version rolls back with audit failure
seonghobae 63b67b1
test(schedule): reject blank work-item authority
seonghobae dc8d1b8
fix(schedule): reject blank work-item authority
seonghobae 72a0ea2
docs(schedule): record blank work-item hardening
seonghobae 68738a7
docs(changelog): record reason-event boundary hardening
seonghobae 8f466dd
merge(schedule): reconcile project-version adapter with persistence p…
seonghobae cbb3806
fix(stack): inherit current reason persistence parent
seonghobae 15d0923
fix(stack): reconcile project-version binding with current persistenc…
seonghobae 22883d1
fix(stack): reconcile project-version binding with latest persistence…
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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), | ||
| }); | ||
| }, | ||
| }); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 parsedtasks_jsonarray. This matches today's flattasksarray persisted in server/app.mjs:272-276. A future move to nested work items would make valid IDs unfindable, silently failing transitions closed.Was this helpful? React with 👍 or 👎 to provide feedback.