From c365c2d33119fb4f92be634b567c9435f0acfcbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:46:38 +0900 Subject: [PATCH 01/15] test(schedule): define outcome derivation contract --- tests/unit/schedule-outcome-domain.test.mjs | 265 ++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 tests/unit/schedule-outcome-domain.test.mjs diff --git a/tests/unit/schedule-outcome-domain.test.mjs b/tests/unit/schedule-outcome-domain.test.mjs new file mode 100644 index 00000000..0584fe57 --- /dev/null +++ b/tests/unit/schedule-outcome-domain.test.mjs @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + SCHEDULE_OUTCOMES, + SCHEDULE_OUTCOME_DERIVATION_VERSION, + deriveScheduleOutcome, +} from '../../server/schedule_outcome_domain.mjs'; + +const baseInput = (overrides = {}) => ({ + baselineVersion: 'baseline-v7', + baselineFinishDate: '2026-03-10', + executionWindowEndDate: '2026-03-10', + asOfDate: '2026-03-09', + actualStartDate: null, + actualFinishDate: null, + progressPercent: 0, + onTimeToleranceDays: 0, + reasonEvent: null, + blockers: [], + ...overrides, +}); + +test('exports a frozen mutually-exclusive outcome vocabulary and derivation version', () => { + assert.deepEqual(SCHEDULE_OUTCOMES, [ + 'not_started', + 'in_progress', + 'completed_early', + 'completed_on_time', + 'completed_late', + 'not_performed', + 'skipped', + 'cancelled', + 'blocked', + ]); + assert.equal(Object.isFrozen(SCHEDULE_OUTCOMES), true); + assert.equal(SCHEDULE_OUTCOME_DERIVATION_VERSION, 'schedule-outcome/v1'); +}); + +test('keeps untouched work not_started until its execution window has concluded', () => { + const result = deriveScheduleOutcome(baseInput()); + + assert.equal(result.outcome, 'not_started'); + assert.equal(result.decisionRequired, null); + assert.equal(result.explanation.executionWindowConcluded, false); + assert.equal(result.explanation.actualEvidencePresent, false); +}); + +test('does not silently label untouched overdue work as failure', () => { + const result = deriveScheduleOutcome(baseInput({ asOfDate: '2026-03-11' })); + + assert.equal(result.outcome, null); + assert.equal(result.decisionRequired, 'record_execution_outcome'); + assert.equal(result.explanation.executionWindowConcluded, true); + assert.equal(result.explanation.actualEvidencePresent, false); +}); + +test('derives in_progress from actual execution evidence without a completion date', () => { + for (const evidence of [ + { actualStartDate: '2026-03-01' }, + { progressPercent: 1 }, + { actualStartDate: '2026-03-01', progressPercent: 75 }, + ]) { + const result = deriveScheduleOutcome(baseInput({ ...evidence, asOfDate: '2026-03-11' })); + assert.equal(result.outcome, 'in_progress'); + assert.equal(result.decisionRequired, null); + } +}); + +test('classifies completion around a symmetric calendar-day tolerance with leap-day arithmetic', () => { + const common = { + baselineFinishDate: '2028-03-01', + executionWindowEndDate: '2028-03-01', + asOfDate: '2028-03-05', + actualStartDate: '2028-02-20', + progressPercent: 100, + onTimeToleranceDays: 1, + }; + + const early = deriveScheduleOutcome(baseInput({ ...common, actualFinishDate: '2028-02-28' })); + const lowerBoundary = deriveScheduleOutcome(baseInput({ ...common, actualFinishDate: '2028-02-29' })); + const exact = deriveScheduleOutcome(baseInput({ ...common, actualFinishDate: '2028-03-01' })); + const upperBoundary = deriveScheduleOutcome(baseInput({ ...common, actualFinishDate: '2028-03-02' })); + const late = deriveScheduleOutcome(baseInput({ ...common, actualFinishDate: '2028-03-03' })); + + assert.equal(early.outcome, 'completed_early'); + assert.equal(early.explanation.finishVarianceDays, -2); + assert.equal(lowerBoundary.outcome, 'completed_on_time'); + assert.equal(lowerBoundary.explanation.finishVarianceDays, -1); + assert.equal(exact.outcome, 'completed_on_time'); + assert.equal(exact.explanation.finishVarianceDays, 0); + assert.equal(upperBoundary.outcome, 'completed_on_time'); + assert.equal(upperBoundary.explanation.finishVarianceDays, 1); + assert.equal(late.outcome, 'completed_late'); + assert.equal(late.explanation.finishVarianceDays, 2); +}); + +test('requires an approved baseline finish before assigning a completion outcome', () => { + const result = deriveScheduleOutcome(baseInput({ + baselineFinishDate: null, + actualStartDate: '2026-03-01', + actualFinishDate: '2026-03-09', + progressPercent: 100, + })); + + assert.equal(result.outcome, null); + assert.equal(result.decisionRequired, 'approve_baseline_finish'); + assert.equal(result.explanation.finishVarianceDays, null); +}); + +test('uses explicit reason events for skipped, cancelled, and not_performed outcomes', () => { + const cases = [ + { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-17', + occurredAt: '2026-03-08T09:30:00Z', + }, + { + type: 'cancelled', + reasonCode: 'scope_removed', + actorId: 'user-17', + occurredAt: '2026-03-08T09:30:00Z', + approvalId: 'approval-42', + }, + { + type: 'not_performed', + reasonCode: 'vendor_unavailable', + actorId: 'owner-9', + occurredAt: '2026-03-11T01:00:00Z', + }, + ]; + + for (const reasonEvent of cases) { + const result = deriveScheduleOutcome(baseInput({ + asOfDate: '2026-03-11', + reasonEvent, + })); + assert.equal(result.outcome, reasonEvent.type); + assert.deepEqual(result.explanation.reasonEvent, reasonEvent); + assert.equal(Object.isFrozen(result.explanation.reasonEvent), true); + } +}); + +test('does not allow not_performed before the execution window concludes', () => { + assert.throws( + () => deriveScheduleOutcome(baseInput({ + reasonEvent: { + type: 'not_performed', + reasonCode: 'owner_confirmed', + actorId: 'owner-9', + occurredAt: '2026-03-09T10:00:00Z', + }, + })), + /not_performed requires a concluded execution window/, + ); +}); + +test('derives blocked only from a currently unresolved recorded blocker', () => { + const result = deriveScheduleOutcome(baseInput({ + actualStartDate: '2026-03-01', + progressPercent: 40, + blockers: [{ + kind: 'dependency', + referenceId: 'dep-88', + recordedAt: '2026-03-05T02:00:00Z', + resolvedAt: null, + }], + })); + const resolved = deriveScheduleOutcome(baseInput({ + actualStartDate: '2026-03-01', + progressPercent: 40, + blockers: [{ + kind: 'dependency', + referenceId: 'dep-88', + recordedAt: '2026-03-05T02:00:00Z', + resolvedAt: '2026-03-06T02:00:00Z', + }], + })); + + assert.equal(result.outcome, 'blocked'); + assert.equal(result.explanation.unresolvedBlockerCount, 1); + assert.equal(resolved.outcome, 'in_progress'); + assert.equal(resolved.explanation.unresolvedBlockerCount, 0); +}); + +test('fails closed on contradictory terminal evidence instead of choosing a convenient label', () => { + assert.throws( + () => deriveScheduleOutcome(baseInput({ + actualFinishDate: '2026-03-09', + progressPercent: 100, + reasonEvent: { + type: 'cancelled', + reasonCode: 'scope_removed', + actorId: 'user-17', + occurredAt: '2026-03-09T10:00:00Z', + approvalId: 'approval-42', + }, + })), + /completed work cannot also carry a terminal reason outcome/, + ); + + assert.throws( + () => deriveScheduleOutcome(baseInput({ + actualStartDate: '2026-03-01', + progressPercent: 10, + reasonEvent: { + type: 'not_performed', + reasonCode: 'owner_confirmed', + actorId: 'owner-9', + occurredAt: '2026-03-11T01:00:00Z', + }, + asOfDate: '2026-03-11', + })), + /not_performed cannot coexist with actual execution evidence/, + ); +}); + +test('returns immutable explanation provenance with source facts and baseline identity', () => { + const reasonEvent = { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-17', + occurredAt: '2026-03-08T09:30:00Z', + }; + const input = baseInput({ reasonEvent }); + const result = deriveScheduleOutcome(input); + + assert.equal(result.derivationVersion, 'schedule-outcome/v1'); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.explanation), true); + assert.deepEqual(result.explanation.sourceFacts, { + baselineVersion: 'baseline-v7', + baselineFinishDate: '2026-03-10', + executionWindowEndDate: '2026-03-10', + asOfDate: '2026-03-09', + actualStartDate: null, + actualFinishDate: null, + progressPercent: 0, + onTimeToleranceDays: 0, + }); + assert.equal(Object.isFrozen(result.explanation.sourceFacts), true); + assert.notEqual(result.explanation.reasonEvent, reasonEvent, 'explanation must not retain mutable caller objects'); +}); + +test('rejects malformed dates, percentages, tolerance, blockers, and reason events', () => { + const invalidInputs = [ + { asOfDate: '2026-02-30' }, + { actualStartDate: '03/01/2026' }, + { progressPercent: -1 }, + { progressPercent: 101 }, + { progressPercent: Number.NaN }, + { onTimeToleranceDays: -1 }, + { onTimeToleranceDays: 1.5 }, + { baselineVersion: ' ' }, + { blockers: [{ kind: 'other', referenceId: 'x', recordedAt: '2026-03-01T00:00:00Z', resolvedAt: null }] }, + { reasonEvent: { type: 'skipped', reasonCode: '', actorId: 'user-1', occurredAt: '2026-03-01T00:00:00Z' } }, + { reasonEvent: { type: 'cancelled', reasonCode: 'scope_removed', actorId: 'user-1', occurredAt: '2026-03-01T00:00:00Z' } }, + { reasonEvent: { type: 'unknown', reasonCode: 'x', actorId: 'user-1', occurredAt: '2026-03-01T00:00:00Z' } }, + ]; + + for (const overrides of invalidInputs) { + assert.throws(() => deriveScheduleOutcome(baseInput(overrides))); + } +}); From c4c03805aaf92fadac4a1ab7747c64708b280286 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:47:34 +0900 Subject: [PATCH 02/15] feat(schedule): derive auditable schedule outcomes --- server/schedule_outcome_domain.mjs | 279 +++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 server/schedule_outcome_domain.mjs diff --git a/server/schedule_outcome_domain.mjs b/server/schedule_outcome_domain.mjs new file mode 100644 index 00000000..d5d8bf07 --- /dev/null +++ b/server/schedule_outcome_domain.mjs @@ -0,0 +1,279 @@ +const DAY_MS = 86_400_000; +const TERMINAL_REASON_TYPES = new Set(['skipped', 'cancelled', 'not_performed']); +const BLOCKER_KINDS = new Set(['dependency', 'decision', 'constraint']); + +/** + * Mutually exclusive schedule outcomes emitted by the ScopeWeave derivation domain. + * A null outcome is intentionally possible when required evidence is missing; it + * is never replaced with a synthetic failure category. + */ +export const SCHEDULE_OUTCOMES = Object.freeze([ + 'not_started', + 'in_progress', + 'completed_early', + 'completed_on_time', + 'completed_late', + 'not_performed', + 'skipped', + 'cancelled', + 'blocked', +]); + +/** Version identifier persisted beside derived outcome evidence. */ +export const SCHEDULE_OUTCOME_DERIVATION_VERSION = 'schedule-outcome/v1'; + +/** + * Derive one decision-ready schedule outcome from explicit baseline and execution facts. + * + * The function deliberately separates observed facts from interpretation. Missing + * approved baseline data or untouched work after its execution window produces a + * null outcome plus an explicit next decision rather than silently inferring failure. + * Explicit skip/cancel/not-performed reason events are validated as auditable facts, + * and unresolved dependency/decision/constraint blockers are treated separately from + * completion status. Returned provenance is immutable and contains no caller-owned + * mutable object references. + * + * Calendar variance uses whole ISO-8601 calendar days in UTC. This makes leap-day + * boundaries deterministic and avoids deployment-time-zone drift. The configurable + * on-time tolerance is symmetric around the approved baseline finish date. + * + * @param {unknown} input schedule facts and explicit reason/blocker evidence + * @returns {Readonly<{ + * outcome: string|null, + * derivationVersion: string, + * decisionRequired: string|null, + * explanation: Readonly> + * }>} immutable outcome decision and provenance + * @throws {TypeError|Error} when supplied evidence is malformed or contradictory + */ +export function deriveScheduleOutcome(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('schedule outcome input must be an object'); + } + + const baselineVersion = requireText(input.baselineVersion, 'baselineVersion'); + const baselineFinish = parseCalendarDate(input.baselineFinishDate, 'baselineFinishDate', true); + const executionWindowEnd = parseCalendarDate(input.executionWindowEndDate, 'executionWindowEndDate'); + const asOf = parseCalendarDate(input.asOfDate, 'asOfDate'); + const actualStart = parseCalendarDate(input.actualStartDate, 'actualStartDate', true); + const actualFinish = parseCalendarDate(input.actualFinishDate, 'actualFinishDate', true); + const progressPercent = requirePercentage(input.progressPercent); + const onTimeToleranceDays = requireTolerance(input.onTimeToleranceDays); + const reasonEvent = normalizeReasonEvent(input.reasonEvent, asOf.epochDay); + const blockers = normalizeBlockers(input.blockers, asOf.epochDay); + + if (actualStart && actualStart.epochDay > asOf.epochDay) { + throw new Error('actualStartDate cannot be after asOfDate'); + } + if (actualFinish && actualFinish.epochDay > asOf.epochDay) { + throw new Error('actualFinishDate cannot be after asOfDate'); + } + if (actualStart && actualFinish && actualFinish.epochDay < actualStart.epochDay) { + throw new Error('actualFinishDate cannot precede actualStartDate'); + } + if (actualFinish && progressPercent !== 100) { + throw new Error('actualFinishDate requires 100 percent progress'); + } + + const actualEvidencePresent = actualStart !== null || actualFinish !== null || progressPercent > 0; + const executionWindowConcluded = asOf.epochDay > executionWindowEnd.epochDay; + const unresolvedBlockers = blockers.filter((blocker) => blocker.resolvedAt === null); + + if (actualFinish && reasonEvent) { + throw new Error('completed work cannot also carry a terminal reason outcome'); + } + if (actualFinish && unresolvedBlockers.length > 0) { + throw new Error('completed work cannot remain blocked'); + } + if (reasonEvent?.type === 'not_performed' && !executionWindowConcluded) { + throw new Error('not_performed requires a concluded execution window'); + } + if (reasonEvent?.type === 'not_performed' && actualEvidencePresent) { + throw new Error('not_performed cannot coexist with actual execution evidence'); + } + + let outcome = null; + let decisionRequired = null; + let finishVarianceDays = null; + + if (actualFinish) { + if (!baselineFinish) { + decisionRequired = 'approve_baseline_finish'; + } else { + finishVarianceDays = actualFinish.epochDay - baselineFinish.epochDay; + if (finishVarianceDays < -onTimeToleranceDays) { + outcome = 'completed_early'; + } else if (finishVarianceDays > onTimeToleranceDays) { + outcome = 'completed_late'; + } else { + outcome = 'completed_on_time'; + } + } + } else if (reasonEvent) { + outcome = reasonEvent.type; + } else if (unresolvedBlockers.length > 0) { + outcome = 'blocked'; + } else if (actualEvidencePresent) { + outcome = 'in_progress'; + } else if (!executionWindowConcluded) { + outcome = 'not_started'; + } else { + decisionRequired = 'record_execution_outcome'; + } + + const sourceFacts = Object.freeze({ + baselineVersion, + baselineFinishDate: baselineFinish?.value ?? null, + executionWindowEndDate: executionWindowEnd.value, + asOfDate: asOf.value, + actualStartDate: actualStart?.value ?? null, + actualFinishDate: actualFinish?.value ?? null, + progressPercent, + onTimeToleranceDays, + }); + const frozenReasonEvent = reasonEvent ? Object.freeze({ ...reasonEvent }) : null; + const frozenBlockers = Object.freeze(blockers.map((blocker) => Object.freeze({ ...blocker }))); + const explanation = Object.freeze({ + sourceFacts, + reasonEvent: frozenReasonEvent, + blockers: frozenBlockers, + actualEvidencePresent, + executionWindowConcluded, + unresolvedBlockerCount: unresolvedBlockers.length, + finishVarianceDays, + }); + + return Object.freeze({ + outcome, + derivationVersion: SCHEDULE_OUTCOME_DERIVATION_VERSION, + decisionRequired, + explanation, + }); +} + +/** @param {unknown} value text input @param {string} field field name @returns {string} */ +function requireText(value, field) { + if (typeof value !== 'string' || value.trim().length === 0 || /[\u0000-\u001f\u007f]/u.test(value)) { + throw new TypeError(`${field} must be non-blank text without control characters`); + } + return value; +} + +/** + * Parse one strict ISO calendar date into its UTC day number. + * @param {unknown} value candidate date + * @param {string} field field name + * @param {boolean} nullable whether null/undefined is accepted + * @returns {{value: string, epochDay: number}|null} + */ +function parseCalendarDate(value, field, nullable = false) { + if ((value === null || value === undefined) && nullable) { + return null; + } + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) { + throw new TypeError(`${field} must be an ISO calendar date`); + } + const timestamp = Date.parse(`${value}T00:00:00.000Z`); + if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== value) { + throw new TypeError(`${field} must be a real ISO calendar date`); + } + return { value, epochDay: timestamp / DAY_MS }; +} + +/** @param {unknown} value candidate progress @returns {number} */ +function requirePercentage(value) { + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new TypeError('progressPercent must be a finite number from 0 through 100'); + } + return value; +} + +/** @param {unknown} value candidate calendar-day tolerance @returns {number} */ +function requireTolerance(value) { + if (!Number.isInteger(value) || value < 0 || value > 365) { + throw new TypeError('onTimeToleranceDays must be an integer from 0 through 365'); + } + return value; +} + +/** + * Normalize one explicit terminal reason event. + * @param {unknown} value candidate reason event + * @param {number} asOfEpochDay current observation day + * @returns {{type: string, reasonCode: string, actorId: string, occurredAt: string, approvalId?: string}|null} + */ +function normalizeReasonEvent(value, asOfEpochDay) { + if (value === null || value === undefined) { + return null; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('reasonEvent must be an object'); + } + if (!TERMINAL_REASON_TYPES.has(value.type)) { + throw new TypeError('reasonEvent.type is unsupported'); + } + const reasonCode = requireText(value.reasonCode, 'reasonEvent.reasonCode'); + const actorId = requireText(value.actorId, 'reasonEvent.actorId'); + const occurredAt = requireTimestamp(value.occurredAt, 'reasonEvent.occurredAt', asOfEpochDay); + if (value.type === 'cancelled') { + return { + type: value.type, + reasonCode, + actorId, + occurredAt, + approvalId: requireText(value.approvalId, 'reasonEvent.approvalId'), + }; + } + return { type: value.type, reasonCode, actorId, occurredAt }; +} + +/** + * Normalize blocker evidence while preserving resolved history. + * @param {unknown} value candidate blocker array + * @param {number} asOfEpochDay current observation day + * @returns {Array<{kind: string, referenceId: string, recordedAt: string, resolvedAt: string|null}>} + */ +function normalizeBlockers(value, asOfEpochDay) { + if (!Array.isArray(value)) { + throw new TypeError('blockers must be an array'); + } + return value.map((blocker) => { + if (!blocker || typeof blocker !== 'object' || Array.isArray(blocker)) { + throw new TypeError('blocker must be an object'); + } + if (!BLOCKER_KINDS.has(blocker.kind)) { + throw new TypeError('blocker.kind is unsupported'); + } + const referenceId = requireText(blocker.referenceId, 'blocker.referenceId'); + const recordedAt = requireTimestamp(blocker.recordedAt, 'blocker.recordedAt', asOfEpochDay); + const resolvedAt = blocker.resolvedAt === null || blocker.resolvedAt === undefined + ? null + : requireTimestamp(blocker.resolvedAt, 'blocker.resolvedAt', asOfEpochDay); + if (resolvedAt !== null && Date.parse(resolvedAt) < Date.parse(recordedAt)) { + throw new Error('blocker.resolvedAt cannot precede blocker.recordedAt'); + } + return { kind: blocker.kind, referenceId, recordedAt, resolvedAt }; + }); +} + +/** + * Validate an auditable timestamp and reject future-dated evidence. + * @param {unknown} value candidate timestamp + * @param {string} field field name + * @param {number} asOfEpochDay current observation day + * @returns {string} + */ +function requireTimestamp(value, field, asOfEpochDay) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${field} must be an ISO timestamp`); + } + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + throw new TypeError(`${field} must be a real timestamp`); + } + const evidenceEpochDay = Math.floor(timestamp / DAY_MS); + if (evidenceEpochDay > asOfEpochDay) { + throw new Error(`${field} cannot be after asOfDate`); + } + return value; +} From 099a29a5d1cb87409151b04a3462398de478f1e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:48:15 +0900 Subject: [PATCH 03/15] test(schedule): register outcome domain coverage --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0ceb72d0..31352022 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/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/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/coverage-script-contract.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/work_item_hierarchy.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/work-item-hierarchy.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/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/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/coverage-script-contract.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/work_item_hierarchy.mjs --include=server/schedule_outcome_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.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", From 95836a4a4d572e11b54dccebbac391446822cea4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:48:37 +0900 Subject: [PATCH 04/15] test(schedule): lock outcome domain into coverage --- tests/unit/coverage-script-contract.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 08a97080..a3a06e90 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /--include=server\/work_item_hierarchy\.mjs/, 'the work-item hierarchy domain is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/schedule_outcome_domain\.mjs/, + 'the schedule-outcome domain is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -49,6 +54,11 @@ assert.match( /tests\/unit\/work-item-hierarchy\.test\.mjs/, 'the work-item hierarchy behavior contract executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/schedule-outcome-domain\.test\.mjs/, + 'the schedule-outcome behavior contract executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, From 5b9d44c2c5b6470f5eda750214410d1e25226827 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:49:28 +0900 Subject: [PATCH 05/15] test(schedule): cover outcome evidence failures --- .../schedule-outcome-domain-edge.test.mjs | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/unit/schedule-outcome-domain-edge.test.mjs diff --git a/tests/unit/schedule-outcome-domain-edge.test.mjs b/tests/unit/schedule-outcome-domain-edge.test.mjs new file mode 100644 index 00000000..16ae7d94 --- /dev/null +++ b/tests/unit/schedule-outcome-domain-edge.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { deriveScheduleOutcome } from '../../server/schedule_outcome_domain.mjs'; + +const baseInput = (overrides = {}) => ({ + baselineVersion: 'baseline-v7', + baselineFinishDate: '2026-03-10', + executionWindowEndDate: '2026-03-10', + asOfDate: '2026-03-11', + actualStartDate: null, + actualFinishDate: null, + progressPercent: 0, + onTimeToleranceDays: 0, + reasonEvent: null, + blockers: [], + ...overrides, +}); + +const expectReject = (overrides, pattern) => { + assert.throws(() => deriveScheduleOutcome(baseInput(overrides)), pattern); +}; + +test('rejects malformed top-level input and required scalar fields', () => { + for (const value of [null, [], 'plan']) { + assert.throws(() => deriveScheduleOutcome(value), /input must be an object/); + } + + expectReject({ baselineVersion: '' }, /baselineVersion/); + expectReject({ baselineVersion: 'bad\nversion' }, /baselineVersion/); + expectReject({ executionWindowEndDate: null }, /executionWindowEndDate/); + expectReject({ executionWindowEndDate: '2026-02-30' }, /executionWindowEndDate/); + expectReject({ actualFinishDate: 'March 9, 2026' }, /actualFinishDate/); + expectReject({ progressPercent: Infinity }, /progressPercent/); + expectReject({ onTimeToleranceDays: 366 }, /onTimeToleranceDays/); + expectReject({ onTimeToleranceDays: '1' }, /onTimeToleranceDays/); +}); + +test('rejects impossible temporal and completion evidence', () => { + expectReject({ actualStartDate: '2026-03-12' }, /actualStartDate cannot be after/); + expectReject({ actualFinishDate: '2026-03-12', progressPercent: 100 }, /actualFinishDate cannot be after/); + expectReject({ + actualStartDate: '2026-03-09', + actualFinishDate: '2026-03-08', + progressPercent: 100, + }, /actualFinishDate cannot precede/); + expectReject({ actualFinishDate: '2026-03-09', progressPercent: 99 }, /requires 100 percent progress/); + expectReject({ + actualFinishDate: '2026-03-09', + progressPercent: 100, + blockers: [{ + kind: 'constraint', + referenceId: 'constraint-7', + recordedAt: '2026-03-01T00:00:00Z', + resolvedAt: null, + }], + }, /completed work cannot remain blocked/); +}); + +test('accepts boundary tolerance and completion without an actual-start record', () => { + const result = deriveScheduleOutcome(baseInput({ + baselineFinishDate: '2026-03-10', + actualFinishDate: '2026-03-11', + progressPercent: 100, + onTimeToleranceDays: 365, + })); + + assert.equal(result.outcome, 'completed_on_time'); + assert.equal(result.explanation.sourceFacts.actualStartDate, null); +}); + +test('validates reason-event object shape, timestamps, and cancellation approval', () => { + expectReject({ reasonEvent: [] }, /reasonEvent must be an object/); + expectReject({ reasonEvent: { type: 'unknown' } }, /reasonEvent.type is unsupported/); + expectReject({ + reasonEvent: { + type: 'skipped', + reasonCode: 'bad\u0000code', + actorId: 'user-1', + occurredAt: '2026-03-01T00:00:00Z', + }, + }, /reasonEvent.reasonCode/); + expectReject({ + reasonEvent: { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: '', + occurredAt: '2026-03-01T00:00:00Z', + }, + }, /reasonEvent.actorId/); + expectReject({ + reasonEvent: { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-1', + occurredAt: 'not-a-time', + }, + }, /reasonEvent.occurredAt/); + expectReject({ + reasonEvent: { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-1', + occurredAt: '2026-03-12T00:00:00Z', + }, + }, /cannot be after asOfDate/); + expectReject({ + reasonEvent: { + type: 'cancelled', + reasonCode: 'scope_removed', + actorId: 'user-1', + occurredAt: '2026-03-01T00:00:00Z', + approvalId: ' ', + }, + }, /reasonEvent.approvalId/); +}); + +test('accepts undefined optional evidence without retaining mutable blocker arrays', () => { + const input = baseInput({ reasonEvent: undefined, blockers: [{ + kind: 'decision', + referenceId: 'decision-3', + recordedAt: '2026-03-01T00:00:00Z', + resolvedAt: undefined, + }] }); + const result = deriveScheduleOutcome(input); + + assert.equal(result.outcome, 'blocked'); + assert.equal(Object.isFrozen(result.explanation.blockers), true); + assert.equal(Object.isFrozen(result.explanation.blockers[0]), true); + assert.notEqual(result.explanation.blockers, input.blockers); +}); + +test('validates blocker containers, kinds, identifiers, and lifecycle timestamps', () => { + expectReject({ blockers: null }, /blockers must be an array/); + expectReject({ blockers: [null] }, /blocker must be an object/); + expectReject({ blockers: [[]] }, /blocker must be an object/); + expectReject({ blockers: [{ + kind: 'risk', + referenceId: 'risk-1', + recordedAt: '2026-03-01T00:00:00Z', + resolvedAt: null, + }] }, /blocker.kind is unsupported/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: '', + recordedAt: '2026-03-01T00:00:00Z', + resolvedAt: null, + }] }, /blocker.referenceId/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '', + resolvedAt: null, + }] }, /blocker.recordedAt/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-12T00:00:00Z', + resolvedAt: null, + }] }, /cannot be after asOfDate/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-03T00:00:00Z', + resolvedAt: 'invalid', + }] }, /blocker.resolvedAt/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-03T00:00:00Z', + resolvedAt: '2026-03-12T00:00:00Z', + }] }, /cannot be after asOfDate/); + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-03T00:00:00Z', + resolvedAt: '2026-03-02T00:00:00Z', + }] }, /cannot precede blocker.recordedAt/); +}); + +test('preserves resolved blocker history without classifying the work as blocked', () => { + const result = deriveScheduleOutcome(baseInput({ + actualStartDate: '2026-03-01', + progressPercent: 20, + blockers: [ + { + kind: 'decision', + referenceId: 'decision-3', + recordedAt: '2026-03-01T00:00:00Z', + resolvedAt: '2026-03-02T00:00:00Z', + }, + { + kind: 'constraint', + referenceId: 'constraint-4', + recordedAt: '2026-03-02T00:00:00Z', + resolvedAt: '2026-03-03T00:00:00Z', + }, + ], + })); + + assert.equal(result.outcome, 'in_progress'); + assert.equal(result.explanation.unresolvedBlockerCount, 0); + assert.equal(result.explanation.blockers.length, 2); +}); From 1e43a1f70174163a8d7633cbd3548204719c2c77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:50:40 +0900 Subject: [PATCH 06/15] test(schedule): cover outcome edge cases in CI --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 31352022..ea913a9b 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/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/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/coverage-script-contract.test.mjs", + "test:unit": "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/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/coverage-script-contract.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/work_item_hierarchy.mjs --include=server/schedule_outcome_domain.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/work-item-hierarchy.test.mjs && node tests/unit/schedule-outcome-domain.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.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/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", From fcdf2d2e766b5c8e39de461d11499f3f96be112c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:51:02 +0900 Subject: [PATCH 07/15] test(schedule): lock edge cases into coverage --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index a3a06e90..a36fe383 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -59,6 +59,11 @@ assert.match( /tests\/unit\/schedule-outcome-domain\.test\.mjs/, 'the schedule-outcome behavior contract executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/schedule-outcome-domain-edge\.test\.mjs/, + 'the schedule-outcome failure-boundary contract executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, From d59e3de6ca01797203dca3f17a397a1fa53c24f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:52:09 +0900 Subject: [PATCH 08/15] docs(schedule): record outcome derivation evidence --- docs/doctoring/schedule-outcome-domain.md | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/doctoring/schedule-outcome-domain.md diff --git a/docs/doctoring/schedule-outcome-domain.md b/docs/doctoring/schedule-outcome-domain.md new file mode 100644 index 00000000..edac1958 --- /dev/null +++ b/docs/doctoring/schedule-outcome-domain.md @@ -0,0 +1,81 @@ +# Schedule outcome derivation domain + +## Status and scope + +This document records **active stacked work**, not protected-`develop` shipped truth. The branch `feat/schedule-outcome-domain-287` is stacked on the exact current head of PR #515 (`feat/work-item-hierarchy-domain-287`) and implements the next bounded domain slice of issue #287. Protected `develop` does not expose these derived outcomes until the prerequisite hierarchy work and this child are independently reviewed, reconciled, integrated, and verified. + +The slice is intentionally framework-neutral. It adds no UI, Hono route, database migration, persistence adapter, tenant authorization rule, forecast model, Waterfall/Agile projection, or entitlement/security behavior. Later adapters may persist a result only together with its derivation version and source-version references; they must not treat this pure domain function as authorization to create or modify source facts. + +## Buyer decision contract + +The domain turns schedule facts into one mutually exclusive decision label while retaining missingness and provenance instead of manufacturing certainty. The versioned vocabulary is: + +- `not_started` +- `in_progress` +- `completed_early` +- `completed_on_time` +- `completed_late` +- `not_performed` +- `skipped` +- `cancelled` +- `blocked` + +`deriveScheduleOutcome()` can also return `outcome: null` with a concrete `decisionRequired` value. That is deliberate: an approved baseline finish is required before ScopeWeave labels a completed item early/on-time/late, and untouched work after its execution window requires an accountable outcome decision rather than an inferred failure. + +Observed facts remain distinct from interpretation. The returned immutable explanation retains baseline identity, baseline finish, execution-window end, observation date, actual start/finish, progress, tolerance, explicit reason evidence, blocker history, unresolved blocker count, and finish variance. The derivation identifier is `schedule-outcome/v1`. + +## Deterministic rules + +Calendar comparisons use strict `YYYY-MM-DD` values converted to UTC calendar-day ordinals. This avoids deployment-time-zone drift and correctly traverses leap days. `onTimeToleranceDays` is a non-negative integer and is applied symmetrically around the approved baseline finish: a variance below `-tolerance` is early, above `+tolerance` is late, and the inclusive interval is on time. + +A finish date is completion evidence only when progress is exactly 100%. An actual start or positive progress without a finish is `in_progress`. An unresolved recorded dependency, decision, or constraint produces `blocked` before ordinary in-progress/not-started classification, but completed work cannot simultaneously remain blocked. + +`skipped`, `cancelled`, and `not_performed` require explicit reason events with actor and timestamp. Cancellation additionally requires an approval identifier. `not_performed` is accepted only after the execution window has concluded and only when no actual execution evidence exists. The domain rejects contradictory terminal evidence rather than selecting a convenient label. + +The exact window rule is conservative: `asOfDate > executionWindowEndDate` means the window has concluded. On the configured end date itself, untouched work remains `not_started`; adapters that need an intraday cutoff must supply an explicit later calendar observation or introduce a separately reviewed timestamp policy rather than smuggling local-clock behavior into this domain. + +## TDD and executable traceability + +The branch was cut from PR #515 exact head `322b8d7de4645f51560419b6a5d8e4826e95964b` after a fresh protected-base and current-head review/check refetch. + +- `c365c2d33119fb4f92be634b567c9435f0acfcbc` added the primary behavior contract while `server/schedule_outcome_domain.mjs` did not exist. The import was therefore structurally RED with `ERR_MODULE_NOT_FOUND`; no hosted result is claimed for that pre-PR commit. +- `c4c03805aaf92fadac4a1ab7747c64708b280286` added the production derivation module. +- `099a29a5d1cb87409151b04a3462398de478f1e5` registered the module and primary behavior contract in the canonical c8 producer. +- `95836a4a4d572e11b54dccebbac391446822cea4` locked that production instrumentation/test registration into the coverage-script contract. +- `5b9d44c2c5b6470f5eda750214410d1e25226827` expanded realistic failure-boundary coverage for malformed evidence, impossible temporal states, blocker lifecycle, and cancellation authority. +- `1e43a1f70174163a8d7633cbd3548204719c2c77` registered those edge cases in normal unit and c8 coverage execution. +- `fcdf2d2e766b5c8e39de461d11499f3f96be112c` locked the edge-case registration into the coverage-script contract. + +Hosted exact-current-head statement/branch/function/line percentages remain authoritative once the PR exists. Predecessor, local-only, pending, skipped-required, neutral, model-only, or status-only evidence is not promoted to passing. + +## Evidence boundaries and buyer safety + +This module does not infer tenant membership, owner accountability, baseline approval, or reason-event authorization. Production adapters must establish those authorities before constructing the input. In particular, a browser-provided reason actor, cancellation approval, baseline identity, or organization identifier cannot become authoritative merely because the domain validates its shape. + +No secret, credential, raw attachment, provider payload, or PII-specific field is required by this slice. Later persistence should store only the source identifiers and audit metadata needed to reproduce the decision, under ScopeWeave's purpose-bound access, tenant isolation, retention, export logging, and recovery controls. + +The domain is deterministic and independent of model judgment. LLM output may later explain or summarize schedule evidence, but it must not replace this deterministic outcome gate or silently mutate the underlying facts. + +## Standards and research rationale + +ISO 21508:2026 is the current published second edition of the earned value management guidance standard. It reinforces integration of scope, schedule, cost, baseline, monitoring, and control evidence, but it does not prescribe ScopeWeave's nine-label outcome taxonomy or this tolerance policy. Those labels are a product decision designed to keep actual facts, approved baselines, explicit reason events, and derived interpretation separate. + +ISO 21513:2026 is the current published guidance for post-project and post-programme evaluation. Its emphasis on actual outcomes and structured evaluation supports preserving reproducible actual-versus-expected evidence rather than erasing source facts after classification. This slice does not claim to implement the full evaluation standard. + +Behavioral research also supports preserving historical and baseline evidence explicitly. Lorko, Servátka, and Zhang's incentivized experiment found persistent anchoring in project-duration estimates, including anchoring on planners' own prior estimates. Their later experiment found that historical information about similar projects improved duration-estimation accuracy more reliably than merely adding project-detail information. ScopeWeave therefore retains baseline identity and variance evidence so later estimation-bias/calibration views can compare plans with realized outcomes without conflating the original estimate with the observed result. + +## Rollback and integration + +Before persistence/API/UI integration, rollback removes `server/schedule_outcome_domain.mjs`, its focused tests, package/coverage registrations, this doctoring record, and the corresponding CHANGELOG entry together. There is no database state to reverse. + +Do not integrate this child independently of #515. After #515 reaches protected `develop`, reconcile this bounded semantic slice against the resulting protected head and rerun every applicable repository-native and organization-required CI/security/dependency/supply-chain/coverage gate. A qualifying independent current-head/last-push approval remains mandatory under the live rulesets; model reviews and author-only evidence do not satisfy it. + +## References + +International Organization for Standardization. (2026). *Project, programme and portfolio management—Earned value management* (ISO Standard No. 21508:2026). https://www.iso.org/standard/87899.html + +International Organization for Standardization. (2026). *Project, programme and portfolio management—Guidance on post-project and post-programme evaluation* (ISO Standard No. 21513:2026). https://www.iso.org/standard/63585.html + +Lorko, M., Servátka, M., & Zhang, L. (2019). Anchoring in project duration estimation. *Journal of Economic Behavior & Organization, 162*, 49–65. https://doi.org/10.1016/j.jebo.2019.04.014 + +Lorko, M., Servátka, M., & Zhang, L. (2021). Improving the accuracy of project schedules. *Production and Operations Management, 30*(6), 1633–1646. https://doi.org/10.1111/poms.13299 From 227e8c3bafbb5b1b46d462de4a9b256cd7dcbd09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 07:52:51 +0900 Subject: [PATCH 09/15] docs(schedule): describe outcome derivation capability --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a251a84b..8cd9c8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a deterministic, framework-neutral schedule-outcome derivation domain + that distinguishes observed facts from versioned early/on-time/late, + in-progress, blocked, skipped, cancelled, and not-performed interpretations; + missing baseline or accountable execution evidence remains explicit instead + of being silently classified as failure. - Added an order-independent four-level Phase → Activity → Task → Duty domain validator/projection that preserves legacy three-level IDs and never synthesizes customer work during projection. From 7de54c8a413978a19d3668633c27a89e8f1a6a34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:50:57 +0900 Subject: [PATCH 10/15] test(release): preserve GitHub Pages 1.0.0 note Strengthen the published-release regression so stacked reconciliation cannot silently delete the GitHub Pages deployment/operator note from the immutable 1.0.0 changelog section. --- tests/unit/changelog-release-notes.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/changelog-release-notes.test.mjs b/tests/unit/changelog-release-notes.test.mjs index d886f3e1..b474f010 100644 --- a/tests/unit/changelog-release-notes.test.mjs +++ b/tests/unit/changelog-release-notes.test.mjs @@ -7,6 +7,7 @@ const changelog = readFileSync(new URL('../../CHANGELOG.md', import.meta.url), ' test('released changelog versions keep their published notes', () => { assert.match(changelog, /## \[1\.0\.0\] - 2026-04-20/); assert.match(changelog, /Initial ScopeWeave Planner release with tree-table editing/); + assert.match(changelog, /GitHub Pages deployment workflow and operator documentation/); assert.match(changelog, /## \[1\.0\.1\] - 2026-06-25/); assert.match(changelog, /O\(1\) 해시맵\(Map\) 기반의 캐싱 조회 로직/); }); From e21772aa9c1f4947855d8f9807ed3e868cfe8ae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:51:28 +0900 Subject: [PATCH 11/15] fix(release): retain published GitHub Pages note Restore the immutable 1.0.0 GitHub Pages deployment/operator release note removed during stacked semantic reconciliation. The preceding regression commit locks this published note against future parent/child rebuilds. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128d1e91..88d0eed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `wbs.json` seed loading plus browser autosave and optional file sync. - Playwright E2E coverage for add/edit hierarchy flows, delete confirmation, subtree drag-and-drop, and JSON sync shape. +- GitHub Pages deployment workflow and operator documentation. ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) From 9864861e386e08087314ff0da79a3c54fd69afd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:51:48 -0700 Subject: [PATCH 12/15] fix(stack): preserve current parent semantics Restore the current #515 hierarchy/orchestrator tree while retaining only the bounded schedule-outcome delta. Preserve tenant-bound orchestrator attribution source/tests/docs and combine package/changelog registrations without transferring stale parent content. --- CHANGELOG.md | 5 + .../contextual-orchestrator-auto-default.md | 41 ++++++ docs/orchestrator-production.md | 22 ++++ package.json | 6 +- server/app.mjs | 5 +- server/orchestrator.mjs | 71 ++++++++++- tests/api/orchestrator-attribution.test.mjs | 89 +++++++++++++ tests/unit/orchestrator-attribution.test.mjs | 117 ++++++++++++++++++ tests/unit/orchestrator.test.mjs | 1 + 9 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 docs/doctoring/contextual-orchestrator-auto-default.md create mode 100644 tests/api/orchestrator-attribution.test.mjs create mode 100644 tests/unit/orchestrator-attribution.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d0eed3..9cd4ab31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switched the repository-local OpenCode development configuration from GitHub Models to an NVIDIA NIM-only candidate set while preserving organization-level review-workflow ownership in `ContextualWisdomLab/.github`. +- Production planning-analysis requests now combine tenant-bound, server-derived + contextual-orchestrator cost attribution with explicit `auto` orchestration + mode, delegating provider/model/topology policy to the shared service without + weakening ScopeWeave's authenticated, fail-closed transport or response + boundary controls. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/docs/doctoring/contextual-orchestrator-auto-default.md b/docs/doctoring/contextual-orchestrator-auto-default.md new file mode 100644 index 00000000..c3d5d2f5 --- /dev/null +++ b/docs/doctoring/contextual-orchestrator-auto-default.md @@ -0,0 +1,41 @@ +# Contextual-orchestrator adaptive planning default + +## Status + +Active pull-request evidence. This record does not describe protected `develop` until the owning pull request is integrated. + +## Decision boundary + +ScopeWeave owns the meaning, authorization, cost attribution, and presentation of a planning-analysis request. The shared `contextual-orchestrator` service owns provider/model selection and the depth/topology of execution. Production ScopeWeave requests therefore send `orchestration_mode: "auto"` explicitly instead of relying on an implicit gateway default or selecting `route`/`conduct` locally. + +The binding dependency evidence verified for this slice is protected `ContextualWisdomLab/contextual-orchestrator` `main` commit `6841b71935e0b7cb98fb52bcb4709cc5100c8d87`. At that revision, `/v1/chat/completions` accepts `orchestration_mode`, permits `auto`, `route`, and `conduct`, accepts bounded attribution metadata, and routes execution through the orchestrator rather than treating the request model label as a provider lock. + +This decision does **not** promise a specific provider, model, worker count, topology, verifier strategy, or cost heuristic. Those remain shared-service policy and may evolve behind its versioned contract. + +## Attribution and tenant authority + +Authenticated project AI briefings attach `service=scopeweave` and the project organization as `account` only after membership-scoped project authorization. Browser request fields cannot select another tenant's accounting identity. The client forwards only supported attribution dimensions, accepts bounded strings or finite numeric identifiers, uses a prototype-free validated map, and omits empty attribution. These labels are accounting metadata and never grant execution-provider or model-selection authority. + +## Security and standalone behavior + +The change preserves the protected ScopeWeave orchestrator boundary: authenticated canonical provider origin, HTTPS outside explicit loopback development, bounded messages, 120-second request timeout, bounded streamed provider responses, sanitized failures, and deterministic text only under explicit `SCOPEWEAVE_DEV=1` development mode. No provider credential or caller-controlled execution policy is added. + +## TDD and overlap-convergence evidence + +The adaptive-mode work originally existed separately in PR #529 while cost attribution occupied the same production request-body boundary in PR #496. Keeping both as independent roots created a concrete future regression risk: whichever branch integrated second could erase the other request field. The older attribution owner was therefore made the canonical combined boundary rather than allowing two competing implementations. + +On the canonical branch, test-only commits `dc71cdff9dc258b8f196c35d9b92c1542e869043` and `5510058ae7437ede44fb7a7fd94351ac7f7d6b14` first require `orchestration_mode: "auto"` both on ordinary hardened requests and while tenant-bound attribution is present or omitted. Source commit `bd8878591bfa74b67ae2a36b122513d2c41e376f` then composes adaptive routing with the existing sanitized attribution request. Exact-current-head hosted evidence remains authoritative; predecessor checks are not reused. + +## Rollback + +Rollback of adaptive mode removes the explicit `orchestration_mode` field and its matching regression/documentation while preserving the tenant-bound attribution and hardened transport. Rollback of attribution separately removes only the attribution call-site, sanitizer, and attribution regressions. Neither rollback may restore stale pre-hardening orchestrator source or a self-modifying workflow. + +## APA 7th references + +Contextual Wisdom Lab. (2026). *contextual-orchestrator* (Commit 6841b71935e0b7cb98fb52bcb4709cc5100c8d87) [Computer software]. GitHub. + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor*. arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Sakana AI. (2026). *Sakana Fugu: Multi-agent system as a model*. https://sakana.ai/fugu/ + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator*. arXiv. https://doi.org/10.48550/arXiv.2512.04695 diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md index c2c4c5c7..e090a17b 100644 --- a/docs/orchestrator-production.md +++ b/docs/orchestrator-production.md @@ -38,6 +38,28 @@ endpoint is absent. That variable must never be set in staging or production. ## Orchestration responsibility +ScopeWeave explicitly sends `orchestration_mode: "auto"` together with the +configured model and validated messages on production briefing requests. The +current protected `ContextualWisdomLab/contextual-orchestrator` `main` contract +verified for this change, commit +`6841b71935e0b7cb98fb52bcb4709cc5100c8d87`, accepts `auto`, `route`, and +`conduct` as orchestration modes. ScopeWeave chooses `auto` as its default so +execution policy can be optimized centrally without coupling this product to a +specific provider, worker count, topology, verifier pattern, or cost heuristic. +Those internal choices remain `contextual-orchestrator` authority and are not a +ScopeWeave compatibility promise. + +For authenticated project AI briefings, ScopeWeave also sends bounded business +cost attribution derived from server-side project state. `service=scopeweave` +and the authenticated project organization `account` are attached only after +membership-scoped project access succeeds. Caller payload fields cannot choose +another tenant's attribution. The client forwards only the orchestration +service's supported attribution dimensions, accepts only bounded string or +finite numeric values, holds validated labels in a prototype-free map, and +omits the attribution object entirely when no valid labels remain. Attribution +is accounting metadata only: it cannot select an execution provider, model, or +orchestration topology. + ScopeWeave intentionally sends only a versioned OpenAI-compatible request to the orchestration service. Model selection, single-model versus multi-agent allocation, task decomposition, role-specific reasoning effort, recursion diff --git a/package.json b/package.json index f9929220..5c2f311a 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/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/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.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/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 --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/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/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/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/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/app.mjs b/server/app.mjs index 03908830..c432a84f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -995,7 +995,10 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { const analysis = await orchestratorChat([ { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, { role: 'user', content: context }, - ]); + ], { + service: 'scopeweave', + account: String(p.org_id), + }); logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); return c.json({ analysis }); } catch (e) { diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index b3e8e400..fccf23d0 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -9,6 +9,17 @@ const MAX_CONTENT_LENGTH = 100_000; const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; // WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const MAX_ATTRIBUTION_VALUE_LENGTH = 256; +const ATTRIBUTION_DIMENSIONS = new Set([ + 'account', + 'service', + 'upstream_api', + 'model_name', + 'team', + 'group', + 'company', + 'provider', +]); export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; @@ -137,6 +148,55 @@ function validatedMessages(messages) { }); } +/** + * Copy optional cost-attribution labels into the exact orchestrator allowlist. + * + * Unknown dimensions and empty values are omitted rather than forwarded to the + * strict contextual-orchestrator request validator. Values must be strings or + * finite numeric identifiers before normalization to bounded strings; complex + * objects and non-finite numbers fail closed instead of becoming misleading + * labels through implicit JavaScript string coercion. Execution model/provider + * identity remains controlled by the top-level request model and the + * orchestrator's own provider routing evidence; this object is business + * cost-allocation metadata only. + * + * @param {unknown} attribution optional business cost-attribution mapping + * @returns {Record|undefined} bounded allowed labels or undefined + */ +function sanitizedAttribution(attribution) { + if (attribution === undefined || attribution === null) return undefined; + if (typeof attribution !== 'object' || Array.isArray(attribution)) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution must be an object when provided.', + ); + } + + const safe = Object.create(null); + for (const [key, value] of Object.entries(attribution)) { + if (!ATTRIBUTION_DIMENSIONS.has(key) || value === undefined || value === null) continue; + if ( + typeof value !== 'string' + && (typeof value !== 'number' || !Number.isFinite(value)) + ) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution values must be strings or finite numbers.', + ); + } + const text = String(value).trim(); + if (!text) continue; + if (text.length > MAX_ATTRIBUTION_VALUE_LENGTH) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution value is outside the accepted boundary.', + ); + } + safe[key] = text; + } + return Object.keys(safe).length ? safe : undefined; +} + /** * Build the stable response-size failure used by declared and streamed limits. * @returns {OrchestratorConfigurationError} Operator-safe size error. @@ -275,11 +335,13 @@ async function rejectProviderResponse(response) { /** * Generate one AI briefing through contextual-orchestrator. * @param {unknown} messages OpenAI-compatible messages + * @param {unknown} [attribution] optional bounded business cost-attribution labels * @returns {Promise} */ -export async function chat(messages) { +export async function chat(messages, attribution) { const configuration = orchestratorConfiguration(); const safeMessages = validatedMessages(messages); + const safeAttribution = sanitizedAttribution(attribution); if (configuration.mock) { const user = safeMessages .filter((message) => message.role === 'user') @@ -303,7 +365,12 @@ export async function chat(messages) { 'content-type': 'application/json', authorization: `Bearer ${configuration.token}`, }, - body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }), + body: JSON.stringify({ + model: OC_MODEL, + orchestration_mode: 'auto', + messages: safeMessages, + ...(safeAttribution ? { attribution: safeAttribution } : {}), + }), signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS), }); } catch { diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..d07460a3 --- /dev/null +++ b/tests/api/orchestrator-attribution.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const providerCalls = []; +globalThis.fetch = async (url, init) => { + providerCalls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { app } = await import(`../../server/app.mjs?attribution-api-test=${Date.now()}`); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function createAccount(email) { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email }), + }); + assert.equal(response.status, 200, `${email} signup`); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); + assert.equal(response.status, 200, `${email} account lookup`); + const account = await response.json(); + return { auth, orgId: account.orgs[0].id }; +} + +const owner = await createAccount('orchestrator-owner@scopeweave.test'); +const outsider = await createAccount('orchestrator-outsider@scopeweave.test'); + +let response = await jsonRequest('/api/projects', { + method: 'POST', + headers: owner.auth, + body: jsonBody({ name: 'Attribution Project' }), +}); +assert.equal(response.status, 200, 'owner creates attribution project'); +const projectId = (await response.json()).id; + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: owner.auth, + body: jsonBody({ account: String(outsider.orgId), service: 'spoofed-client-service' }), +}); +assert.equal(response.status, 200, 'authorized owner receives AI briefing'); +assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); +assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); +const providerBody = JSON.parse(providerCalls[0].init.body); +assert.deepEqual( + providerBody.attribution, + { service: 'scopeweave', account: String(owner.orgId) }, + 'the authenticated server-side project organization owns cost attribution', +); +assert.notEqual( + providerBody.attribution.account, + String(outsider.orgId), + 'browser-supplied account data cannot spoof another tenant attribution', +); + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: outsider.auth, + body: jsonBody({ account: String(owner.orgId) }), +}); +assert.equal(response.status, 404, 'cross-tenant AI briefing hides project existence'); +assert.equal( + providerCalls.length, + 1, + 'cross-tenant requests are rejected before any contextual-orchestrator call', +); + +console.log('✓ AI briefing attribution tenant-boundary tests passed'); diff --git a/tests/unit/orchestrator-attribution.test.mjs b/tests/unit/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..45934f6e --- /dev/null +++ b/tests/unit/orchestrator-attribution.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DEV = ''; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const calls = []; +globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { chat } = await import( + `../../server/orchestrator.mjs?attribution-test=${Date.now()}-${Math.random()}` +); + +const messages = [{ role: 'user', content: 'status' }]; + +assert.equal( + await chat(messages, { + service: 'scopeweave', + account: 42, + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', + team: null, + group: '', + company: ' ', + unsupported_dimension: 'must-not-cross-boundary', + }), + 'Grounded production response', +); + +assert.equal(calls.length, 1); +const attributedBody = JSON.parse(calls[0].init.body); +assert.equal(attributedBody.model, 'nvidia/nemotron-3-super-120b-a12b'); +assert.equal(attributedBody.orchestration_mode, 'auto'); +assert.equal(Object.hasOwn(attributedBody, 'provider'), false); +assert.deepEqual(attributedBody.attribution, { + service: 'scopeweave', + account: '42', + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', +}); +assert.equal( + Object.hasOwn(attributedBody.attribution, 'unsupported_dimension'), + false, + 'unknown attribution keys never cross the ScopeWeave boundary', +); + +await chat(messages, { unsupported_dimension: 'x', account: ' ' }); +const emptyBody = JSON.parse(calls[1].init.body); +assert.equal(emptyBody.orchestration_mode, 'auto'); +assert.equal( + Object.hasOwn(emptyBody, 'attribution'), + false, + 'an attribution field is omitted when no non-empty allowed dimensions remain', +); + +await chat(messages); +const legacyBody = JSON.parse(calls[2].init.body); +assert.deepEqual( + legacyBody, + { + model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', + messages, + }, + 'omitting attribution preserves the hardened adaptive request shape exactly', +); + +for (const invalidAttribution of [ + [], + 'scopeweave', + { service: 'x'.repeat(257) }, + { service: ['scopeweave'] }, + { account: { organization_id: 42 } }, + { team: Symbol('scopeweave') }, + { group: Number.NaN }, + { company: Number.POSITIVE_INFINITY }, +]) { + await assert.rejects( + chat(messages, invalidAttribution), + (error) => error.code === 'orchestrator_attribution_invalid', + 'malformed, non-scalar, non-finite, or unbounded attribution fails before provider transport', + ); +} +assert.equal(calls.length, 3, 'invalid attribution never reaches the provider'); + +const originalJsonStringify = JSON.stringify; +let serializedAttributionPrototype; +JSON.stringify = (value, ...args) => { + if (value?.attribution) { + serializedAttributionPrototype = Object.getPrototypeOf(value.attribution); + } + return originalJsonStringify(value, ...args); +}; +try { + await chat(messages, { service: 'scopeweave' }); +} finally { + JSON.stringify = originalJsonStringify; +} +assert.equal( + serializedAttributionPrototype, + null, + 'validated attribution is held in a prototype-free map before provider serialization', +); +assert.equal(calls.length, 4, 'prototype-free attribution still reaches the provider once'); + +console.log('✓ orchestrator attribution boundary tests passed'); \ No newline at end of file diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 14de7136..87cfb647 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -98,6 +98,7 @@ try { assert.ok(calls[0].init.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(calls[0].init.body), { model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', messages: [{ role: 'user', content: 'status' }], }); From d889b74f50f398bfb9dad100126c888f2c6d9e79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 18:49:08 -0700 Subject: [PATCH 13/15] fix(stack): preserve current parent dependency baseline --- package-lock.json | 30 +++++++++++++++--------------- package.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index da668c20..00a99254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" }, @@ -81,19 +81,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@types/istanbul-lib-coverage": { @@ -581,35 +581,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/pure-rand": { diff --git a/package.json b/package.json index 59a91520..416ce850 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "hono": "^4.13.0" }, "devDependencies": { - "@playwright/test": "1.61.1", + "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" } From 3bfa11fb96270fd3a0802dba222f62e45d35ef38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 10:09:49 -0700 Subject: [PATCH 14/15] test(schedule): reject timezone-ambiguous audit timestamps --- .../schedule-outcome-domain-edge.test.mjs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/schedule-outcome-domain-edge.test.mjs b/tests/unit/schedule-outcome-domain-edge.test.mjs index 16ae7d94..2b92ef15 100644 --- a/tests/unit/schedule-outcome-domain-edge.test.mjs +++ b/tests/unit/schedule-outcome-domain-edge.test.mjs @@ -115,6 +115,42 @@ test('validates reason-event object shape, timestamps, and cancellation approval }, /reasonEvent.approvalId/); }); +test('requires explicit timezone offsets for auditable timestamps', () => { + expectReject({ + reasonEvent: { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-1', + occurredAt: '2026-03-10T23:30:00', + }, + }, /explicit UTC offset/); + + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-10T23:30:00', + resolvedAt: null, + }] }, /explicit UTC offset/); + + expectReject({ blockers: [{ + kind: 'dependency', + referenceId: 'dep-1', + recordedAt: '2026-03-09T23:30:00+09:00', + resolvedAt: '2026-03-10T00:30:00', + }] }, /explicit UTC offset/); + + const explicitOffset = deriveScheduleOutcome(baseInput({ + reasonEvent: { + type: 'skipped', + reasonCode: 'duplicate_scope', + actorId: 'user-1', + occurredAt: '2026-03-10T23:30:00+09:00', + }, + })); + assert.equal(explicitOffset.outcome, 'skipped'); + assert.equal(explicitOffset.explanation.reasonEvent.occurredAt, '2026-03-10T23:30:00+09:00'); +}); + test('accepts undefined optional evidence without retaining mutable blocker arrays', () => { const input = baseInput({ reasonEvent: undefined, blockers: [{ kind: 'decision', From 73ce4227d761c79a9fcad39b0455fec4b35b6274 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 21:12:24 -0700 Subject: [PATCH 15/15] fix(schedule): require explicit offsets for audit timestamps --- server/schedule_outcome_domain.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/schedule_outcome_domain.mjs b/server/schedule_outcome_domain.mjs index d5d8bf07..8a2db9f4 100644 --- a/server/schedule_outcome_domain.mjs +++ b/server/schedule_outcome_domain.mjs @@ -1,6 +1,7 @@ const DAY_MS = 86_400_000; const TERMINAL_REASON_TYPES = new Set(['skipped', 'cancelled', 'not_performed']); const BLOCKER_KINDS = new Set(['dependency', 'decision', 'constraint']); +const EXPLICIT_OFFSET_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u; /** * Mutually exclusive schedule outcomes emitted by the ScopeWeave derivation domain. @@ -267,6 +268,9 @@ function requireTimestamp(value, field, asOfEpochDay) { if (typeof value !== 'string' || value.trim().length === 0) { throw new TypeError(`${field} must be an ISO timestamp`); } + if (!EXPLICIT_OFFSET_TIMESTAMP.test(value)) { + throw new TypeError(`${field} must include an explicit UTC offset`); + } const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) { throw new TypeError(`${field} must be a real timestamp`);