-
Notifications
You must be signed in to change notification settings - Fork 0
feat(schedule): establish four-level work-item hierarchy domain #515
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
Open
seonghobae
wants to merge
17
commits into
develop
Choose a base branch
from
feat/work-item-hierarchy-domain-287
base: develop
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.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
29ef935
test(schedule): define four-level hierarchy domain contract
seonghobae bcc5f70
feat(schedule): add four-level hierarchy domain projection
seonghobae 8299740
test(schedule): wire hierarchy domain into unit and coverage gates
seonghobae ba26b89
docs(test): harden hierarchy evidence and traceability
seonghobae 458cc39
test(schedule): preserve persisted projection fields
seonghobae 8f49810
fix(schedule): isolate hierarchy projection metadata
seonghobae da31ed0
test(schedule): assert projection immutability
seonghobae f9cdca2
docs(changelog): describe hierarchy projection precisely
seonghobae 322b8d7
docs(schedule): record hierarchy review repair evidence
seonghobae 9389bdd
merge: reconcile work-item hierarchy with protected develop
seonghobae f143786
merge(schedule): reconcile hierarchy domain with current develop
seonghobae b23639e
merge(schedule): reconcile work-item hierarchy with current develop
seonghobae 2f082eb
merge(develop): reconcile hierarchy domain with OpenCode config
seonghobae ca21114
merge(develop): preserve hierarchy and orchestrator coverage
seonghobae 7e5278f
fix(stack): preserve protected orchestrator changelog
seonghobae 72725c6
fix(stack): reconcile work-item hierarchy with protected develop
seonghobae 36c11dd
Merge branch 'develop' into feat/work-item-hierarchy-domain-287
opencode-agent[bot] 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,43 @@ | ||
| # Work-item hierarchy domain evidence | ||
|
|
||
| Status: **active PR evidence; not protected-`develop` shipped behavior**. | ||
|
|
||
| Issue #287 calls for a four-level Phase → Activity → Task → Duty planning model without destroying existing three-level plans. Protected `develop` currently stores explicit `id`, `parentId`, and `depth` fields and its editor validates only depths 1–3. This slice deliberately does not change the browser editor, database schema, APIs, exports, or persisted records. It establishes the framework-independent domain boundary that those adapters can reuse in a later vertical integration. | ||
|
|
||
| ## Decision and customer invariant | ||
|
|
||
| `server/work_item_hierarchy.mjs` defines a fixed four-level vocabulary and two operations: validate persisted relationships and project valid records into a canonical read model. Each projection entry is an immutable wrapper containing an immutable `record` copy plus derived `kind` and `sourceIndex` metadata. Persisted values remain inside `record`, so customer fields named `kind` or `sourceIndex` cannot be silently overwritten by projection metadata. Parent absence is normalized to `null` in the projected record; every other customer-supplied field, ID, relationship, and source order is preserved. Existing three-level plans remain three-level; a Duty is never synthesized merely to make the structure four levels deep. | ||
|
|
||
| The validator fails closed on malformed record containers, blank/non-string/unsafe IDs, duplicate IDs, depth outside 1–4, missing parents, parents that are not exactly one level above the child, and parent-reference cycles. Validation is independent of input order so imported or database-returned records do not need to be physically grouped before structural integrity can be established. The implementation uses maps and bounded parent traversal rather than nested full-list scans; a realistic 10,000-record fixture is part of the behavior suite. | ||
|
|
||
| This is a domain contract, not a claim that ScopeWeave's current UI already edits Duty records. A later integration must update editor labels/validation, import/export, persistence/API contracts, analytics traversal, browser acceptance evidence, and migration/rollback documentation together before issue #287 can claim four-level support as shipped truth. | ||
|
|
||
| ## TDD and review evidence | ||
|
|
||
| The first branch commit, `29ef935f9356c59b88eda2d1c648acb78848781b`, added `tests/unit/work-item-hierarchy.test.mjs` importing an absent `server/work_item_hierarchy.mjs`. The target module did not exist on the branch or protected base, so the executable contract was RED with `ERR_MODULE_NOT_FOUND` before implementation. Commit `bcc5f70200dc3d2a45fcb6acf0dd427485a93c32` then added the initial production module. | ||
|
|
||
| A current-source CodeRabbit review of predecessor head `ba26b89260ba8ac5142fc7c2ea76c0bb4f475036` identified a valid data-integrity defect in that initial projection shape: spreading a persisted record and then assigning derived `kind` and `sourceIndex` could silently replace customer fields with the same names. Regression commit `458cc39ba0a7a6f66bf35ea8d0533c6c4554fddb` changed the executable contract first to require persisted collision fields to survive under a separate `record` structure; that regression was incompatible with the predecessor production shape and therefore established the repair RED condition. Production commit `8f4981060d3c35776657d2fb90c12e7594daaa7b` moved derived metadata onto an immutable wrapper, preserving the normalized record separately. Commit `da31ed0cce6970d54544af6391d3bbbd968565a7` added direct `Object.isFrozen(...)` assertions for the level vocabulary, wrappers, and projected records, and commit `f9cdca28d1e2c276b61019a98453a82de1420c20` corrected the changelog wording from migration to projection. | ||
|
|
||
| The repository coverage producer instruments the module and executes the hierarchy suite under `c8`. Hosted exact-current-head Istanbul statement/branch/function/line results and all other applicable checks remain mandatory before merge; queued, pending, predecessor, skipped-required, neutral, or model-only evidence is not promoted to passing. | ||
|
|
||
| ## Standards traceability | ||
|
|
||
| The current published international WBS standard is ISO 21511:2018. ISO marks it as published but under revision (stage 90.92). ISO/DIS 21511 Edition 2 is under development and therefore is research/forward-compatibility context, not a final normative dependency. The implementation follows the stable concept that a WBS is a hierarchical decomposition of project scope while intentionally keeping ScopeWeave's product-specific four-level labels separate from any claim that ISO mandates those exact four names or exactly four levels. | ||
|
|
||
| PMI's *Practice Standard for Work Breakdown Structures—Third Edition* likewise treats the WBS as organizing total project scope and explicitly covers predictive, agile, iterative, and incremental life cycles. That supports keeping the domain neutral to delivery method; the Phase/Activity/Task/Duty vocabulary is a ScopeWeave product decision, not an assertion that agile work must fit a waterfall ontology. | ||
|
|
||
| No empirical performance or psychometric claim is introduced by this slice, so a peer-reviewed experimental citation would not materially justify the structural validator. The executable 10,000-record regression is the relevant evidence for its algorithmic workload boundary; later UX latency claims must be backed by browser measurements on the integrated UI. | ||
|
|
||
| ### APA 7 references | ||
|
|
||
| International Organization for Standardization. (2018). *Work breakdown structures for project and programme management* (ISO Standard No. 21511:2018). https://www.iso.org/standard/69702.html | ||
|
|
||
| International Organization for Standardization. (2026). *Project, programme and portfolio management—Work breakdown structures* (ISO/DIS 21511, Edition 2) [Draft International Standard]. https://www.iso.org/standard/87898.html | ||
|
|
||
| Project Management Institute. (2019). *Practice standard for work breakdown structures* (3rd ed.). Project Management Institute. https://www.pmi.org/standards/work-breakdown-structures-third-edition | ||
|
|
||
| ## Integration and rollback | ||
|
|
||
| Integration order for this slice is intentionally narrow: first land the independently reviewed domain contract, then adapt one production boundary at a time while preserving legacy IDs and validating migrations against protected truth. Database object ownership remains with the schema-migration lane; this slice creates no table or migration and therefore cannot conflict with issue #433 / PR #500. | ||
|
|
||
| Rollback before adapter integration removes the domain module, focused tests, coverage registrations, this doctoring note, and the changelog entry together. Because no stored record is transformed and no schema is changed, rollback has no data-reversal step. Once future adapters consume the domain, rollback must preserve customer IDs and must not silently flatten or discard fourth-level work. |
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,176 @@ | ||
| const UNSAFE_IDENTIFIER_KEYS = new Set(['__proto__', 'constructor', 'prototype']); | ||
|
|
||
| /** | ||
| * Canonical ScopeWeave hierarchy labels indexed by persisted depth minus one. | ||
| * The array is frozen so adapters cannot silently redefine the shared domain. | ||
| */ | ||
| export const WORK_ITEM_LEVELS = Object.freeze(['phase', 'activity', 'task', 'duty']); | ||
|
|
||
| /** | ||
| * Validate persisted work-item relationships for ScopeWeave's four-level plan. | ||
| * | ||
| * Validation is order-independent and intentionally does not repair customer | ||
| * data. A record must have an opaque non-empty string ID, a depth from 1 to 4, | ||
| * and either no parent at depth 1 or an existing parent exactly one level | ||
| * above it. Duplicate IDs, cycles, malformed records, and unsafe object-key | ||
| * identifiers are reported explicitly so storage/API adapters can fail closed. | ||
| * | ||
| * @param {unknown} records persisted work-item records to validate | ||
| * @returns {{valid: boolean, errors: Array<Record<string, unknown>>}} stable validation result | ||
| */ | ||
| export function validateWorkItemHierarchy(records) { | ||
| if (!Array.isArray(records)) { | ||
| return { valid: false, errors: [{ code: 'invalid_records' }] }; | ||
| } | ||
|
|
||
| const errors = []; | ||
| const recordById = new Map(); | ||
|
|
||
| for (let sourceIndex = 0; sourceIndex < records.length; sourceIndex += 1) { | ||
| const record = records[sourceIndex]; | ||
| if (!record || typeof record !== 'object' || Array.isArray(record)) { | ||
| errors.push({ code: 'invalid_record', sourceIndex }); | ||
| continue; | ||
| } | ||
|
|
||
| const { id } = record; | ||
| if (!isValidIdentifier(id)) { | ||
| errors.push({ code: 'invalid_id', sourceIndex }); | ||
| continue; | ||
| } | ||
|
|
||
| if (recordById.has(id)) { | ||
| errors.push({ code: 'duplicate_id', id }); | ||
| continue; | ||
| } | ||
|
|
||
| recordById.set(id, record); | ||
| } | ||
|
|
||
| for (const [id, record] of recordById) { | ||
| const { depth } = record; | ||
| if (!Number.isInteger(depth) || depth < 1 || depth > WORK_ITEM_LEVELS.length) { | ||
| errors.push({ code: 'invalid_depth', id, depth }); | ||
| continue; | ||
| } | ||
|
|
||
| const parentId = normalizeParentId(record.parentId); | ||
| if (depth === 1) { | ||
| if (parentId !== null) { | ||
| errors.push({ code: 'invalid_parent_depth', id, parentId, depth, parentDepth: null }); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| if (!isValidIdentifier(parentId) || !recordById.has(parentId)) { | ||
| errors.push({ code: 'missing_parent', id, parentId }); | ||
| continue; | ||
| } | ||
|
|
||
| const parentDepth = recordById.get(parentId)?.depth; | ||
| if (!Number.isInteger(parentDepth) || parentDepth !== depth - 1) { | ||
| errors.push({ code: 'invalid_parent_depth', id, parentId, depth, parentDepth }); | ||
| } | ||
| } | ||
|
|
||
| const cycleIds = findCycleIds(recordById); | ||
| for (const id of cycleIds) { | ||
| errors.push({ code: 'cycle', id }); | ||
| } | ||
|
|
||
| return { valid: errors.length === 0, errors }; | ||
| } | ||
|
|
||
| /** | ||
| * Create immutable, source-position-preserving projection wrappers for a valid plan. | ||
| * | ||
| * Existing three-level plans stay three-level. Every persisted field remains in | ||
| * the nested `record`, including customer fields named `kind` or `sourceIndex`. | ||
| * Canonical hierarchy metadata is stored only on the wrapper, so projection | ||
| * cannot silently overwrite persisted values. A fourth-level Duty is represented | ||
| * only when a source record already exists; this function never synthesizes work. | ||
| * | ||
| * @param {unknown} records persisted ScopeWeave work-item records | ||
| * @returns {Array<Readonly<{record: Readonly<Record<string, unknown>>, kind: string, sourceIndex: number}>>} canonical projected records | ||
| * @throws {Error} when the hierarchy is malformed | ||
| */ | ||
| export function projectWorkItemHierarchy(records) { | ||
| const validation = validateWorkItemHierarchy(records); | ||
| if (!validation.valid) { | ||
| const codes = validation.errors.map((error) => error.code).join(','); | ||
| throw new Error(`Invalid work-item hierarchy: ${codes}`); | ||
| } | ||
|
|
||
| return records.map((record, sourceIndex) => { | ||
| const projectedRecord = Object.freeze({ | ||
| ...record, | ||
| parentId: normalizeParentId(record.parentId), | ||
| }); | ||
| return Object.freeze({ | ||
| record: projectedRecord, | ||
| kind: WORK_ITEM_LEVELS[record.depth - 1], | ||
| sourceIndex, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| /** @param {unknown} id candidate opaque identifier @returns {id is string} */ | ||
| function isValidIdentifier(id) { | ||
| return typeof id === 'string' | ||
| && id.trim().length > 0 | ||
| && !UNSAFE_IDENTIFIER_KEYS.has(id); | ||
| } | ||
|
|
||
| /** @param {unknown} parentId persisted parent value @returns {unknown} canonical parent value */ | ||
| function normalizeParentId(parentId) { | ||
| return parentId === undefined || parentId === null || parentId === '' ? null : parentId; | ||
| } | ||
|
|
||
| /** | ||
| * Find every record that participates in a parent-reference cycle. | ||
| * Invalid/missing parents terminate traversal and are handled by relationship | ||
| * validation, so cycle detection remains bounded to O(N) map traversal. | ||
| * | ||
| * @param {Map<string, Record<string, unknown>>} recordById validated ID map | ||
| * @returns {string[]} cycle participant IDs in deterministic insertion order | ||
| */ | ||
| function findCycleIds(recordById) { | ||
| const stateById = new Map(); | ||
| const cycleSet = new Set(); | ||
|
|
||
| for (const startId of recordById.keys()) { | ||
| if (stateById.get(startId) === 2) { | ||
| continue; | ||
| } | ||
|
|
||
| const path = []; | ||
| const pathIndex = new Map(); | ||
| let currentId = startId; | ||
|
|
||
| while (recordById.has(currentId) && stateById.get(currentId) !== 2) { | ||
| if (pathIndex.has(currentId)) { | ||
| const cycleStart = pathIndex.get(currentId); | ||
| for (let index = cycleStart; index < path.length; index += 1) { | ||
| cycleSet.add(path[index]); | ||
| } | ||
| break; | ||
| } | ||
|
|
||
| pathIndex.set(currentId, path.length); | ||
| path.push(currentId); | ||
| stateById.set(currentId, 1); | ||
|
|
||
| const parentId = normalizeParentId(recordById.get(currentId)?.parentId); | ||
| if (!isValidIdentifier(parentId) || !recordById.has(parentId)) { | ||
| break; | ||
| } | ||
| currentId = parentId; | ||
| } | ||
|
|
||
| for (const id of path) { | ||
| stateById.set(id, 2); | ||
| } | ||
| } | ||
|
|
||
| return [...recordById.keys()].filter((id) => cycleSet.has(id)); | ||
| } | ||
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
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.