diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b9410..c57881b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Portable Project Work Contract (draft).** Add the `project-work/v0` + standard, JSON Schema family (`ready-packet`, `project-state`, + `control-record`, `progress-event`), `contract.json` entry manifest, README, + examples, and structural rejects — a source-neutral contract for exchanging + ready work and project projections without importing a board or runtime. +- **ADR-0008: project-work contract.** Record `project-work/v0` as a proposed + companion portable contract family. Promotion to a stable version follows + ADR-0001. + +### Changed + +- **Validation coverage.** `make check` now runs the project-work control + battery (examples, structural rejects, classifier-key alignment) and the + project-work contract manifest. + ## [0.1.28] - 2026-08-20 ### Added diff --git a/Makefile b/Makefile index c05952e..45a9094 100644 --- a/Makefile +++ b/Makefile @@ -354,6 +354,8 @@ lint-config: lint-role-prompts lint-coverage-attestation ## Validate config data sh scripts/test-agent-wait-controls.sh || exit 1; \ echo " Service-job controls..."; \ sh scripts/test-service-job-controls.sh || exit 1; \ + echo " Project-work controls..."; \ + sh scripts/test-project-work-controls.sh || exit 1; \ echo " Validating contract manifests..."; \ sh scripts/validate-contract-manifests.sh \ schemas/data-artifact/v0/contract.json \ @@ -361,7 +363,8 @@ lint-config: lint-role-prompts lint-coverage-attestation ## Validate config data schemas/process-run/v0/contract.json \ schemas/review-journal/v0/contract.json \ schemas/agent-wait/v0/contract.json \ - schemas/service-job/v0/contract.json || exit 1; \ + schemas/service-job/v0/contract.json \ + schemas/project-work/v0/contract.json || exit 1; \ else \ echo "[!!] goneat not found, skipping config validation"; \ fi diff --git a/README.md b/README.md index ab62ded..9af81db 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ This is a **reference-based model**—we don't sync standards into repositories. │ ├── data-artifact/v0/ # Portable data artifact contract │ ├── foundation/v0/ # Lifecycle phases, release phases, types │ ├── process-run/v0/ # Local process telemetry/control (proposed) +│ ├── project-work/v0/ # Ready packets, project state, work ledger (draft) │ └── service-job/v0/ # Portable service job contract └── scripts/ # Release and automation scripts ``` diff --git a/docs/README.md b/docs/README.md index 7f70f5e..eb2f884 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,7 @@ Cross-cutting classification standards for data and artifacts, plus contract sta - [classifiers-framework.md](standards/classifiers-framework.md) - How the classifiers system fits together (docs + config + schemas) - [data-artifact-contract.md](standards/data-artifact-contract.md) - Portable artifact, representation, catalog, provenance, and protection contract - [service-job-contract.md](standards/service-job-contract.md) - Portable catalog, digest-bound submit, and job lifecycle contract +- [project-work-contract.md](standards/project-work-contract.md) - Portable ready-packet, project-state, control-record, and progress-ledger contract - [data-artifact-contract-examples.md](standards/data-artifact-contract-examples.md) - Source-neutral stress cases and producer adoption preview template - [data-sensitivity-classification.md](standards/data-sensitivity-classification.md) - Sensitivity levels (UNKNOWN, 0-6) - [volatility-classification.md](standards/volatility-classification.md) - Update cadence (static → streaming) diff --git a/docs/decisions/ADR-0008-project-work-contract.md b/docs/decisions/ADR-0008-project-work-contract.md new file mode 100644 index 0000000..f273f35 --- /dev/null +++ b/docs/decisions/ADR-0008-project-work-contract.md @@ -0,0 +1,179 @@ +--- +id: "ADR-0008" +title: "Portable Project Work as a Companion Contract" +status: "proposed" +date: "2026-08-21" +last_updated: "2026-08-21" +deciders: + - "@3leapsdave" +scope: "Crucible foundation / project-work contracts" +tags: + - "schemas" + - "project-work" + - "tasking" + - "interchange-contract" +relates-to: + - "crucible ADR-0001 (schema/config versioning — the promotion gate)" + - "crucible ADR-0006 (process-run — companion-contract and event-envelope precedent)" + - "docs/standards/data-artifact-contract.md (published ledger representations)" +--- + +# ADR-0008: Portable Project Work as a Companion Contract + +## Status + +**Current Status**: Proposed — the contract enters its own review and adoption +lane at `v0`. Promotion to a stable version follows ADR-0001 and requires a +proven downstream producer and consumer. + +## Context + +Repositories and tools need to exchange bounded work without importing a +particular board, scheduler, database, or project-management product. The +shared minimum is broader than a task title and status string: + +- an executable packet needs an outcome, accountable owner, acceptance + criteria, dependencies, and escalation; +- project leadership needs an inspectable project projection without embedding + a recursive task tree; +- status, blockers, and decisions need portable records; +- consumers need an ordered progress ledger whose lifecycle transitions can be + projected deterministically. + +Keeping these shapes product-local would create incompatible task dialects. +Making a product runtime canonical would also force adopters to install an +unrelated tool merely to validate a work packet. A prose convention alone +would not give producers and consumers an executable compatibility surface. + +## Decision + +Establish `contract: project-work/v0` as a companion portable contract family +in Crucible: + +- `docs/standards/project-work-contract.md` is the normative standard. +- `schemas/project-work/v0/contract.json` is the capability manifest. +- `ready-packet.schema.json` is the entry schema and dispatchable root. +- `project-state.schema.json` is an inspectable project projection. +- `control-record.schema.json` represents status, blocker, and decision + records. +- `progress-event.schema.json` represents one ordered NDJSON ledger line. + +The family uses the same hostless capability-resolution posture as the other +portable contracts. Schema publication URLs are retrieval locations, not +instance identity. + +### One family, four governed objects + +The packet, project projection, control record, and progress event version +together under one capability. They describe different views of the same work +domain and share identity, lifecycle, ownership, and classification semantics. +Splitting them into independently versioned families would allow those shared +semantics to drift. + +### Flat work, sibling projects + +Work packets are flat. Dependencies are explicit edges; recursive +`children[]` and parent-task containment are outside the portable floor. A +project is a sibling object referenced by identity, not a task that contains +other tasks. Products may render hierarchy, columns, milestones, or grouping, +but those views do not change the interchange taxonomy. + +### Semantic lifecycle, not board columns + +Packets and projects use the closed lifecycle classes: + +`draft | ready | active | blocked | review | done | cancelled` + +These are portable meanings, not UI column names. Product-specific states map +onto them. Dispatch readiness and acceptance requirements are defined by the +standard rather than inferred from a column label. + +### Portable ownership and escalation + +A packet has one accountable owner expressed as a person, role, or explicitly +unassigned actor. Ready work cannot be unassigned. Role identities follow the +portable role-slug shape; the contract does not enumerate one organization’s +seats or human directory. + +### Deterministic, store-neutral progress + +The progress ledger is append-only NDJSON. Every event identifies exactly one +packet or project, carries an actor and per-subject sequence, and gives +unambiguous lifecycle-transition data. Timestamps describe occurrence time; +they are not ordering or deduplication keys. + +The contract does not select a database. Implementations may project the +ledger into local SQL, analytical tables, columnar artifacts, or other query +stores. When a ledger is published as a data product, the data-artifact +contract governs the published bag while `progress-event` remains the line +contract. + +### Reuse classifier dimensions + +Packets and projects may carry the existing sensitivity, access-tier, and +retention-lifecycle classifier keys. Sensitivity and access tier travel as a +compatible pair. This family does not mint work-specific copies of those +dimensions. + +### Product overlays remain downstream + +Boards, timers, sprint identifiers, wait-state machinery, query engines, +product frontmatter, and persistence layouts remain implementation concerns. +They may extend or map to this floor but do not become alternate sources of +truth for the portable contract. + +## Relationship to Other Contracts + +- `process-run/v0` describes execution telemetry and live process control; it + does not replace a work packet or project projection. +- `data-artifact/v0` describes a published ledger representation; it does not + replace the event-line schema. +- `review-journal/v0` describes relied-upon review evidence; it does not become + the general work ledger. +- `agent-wait/v0` describes waiting and wake outcomes; live wait state remains + outside a work packet. + +## Consequences + +- Repositories can emit and validate ready work before adopting a project + runtime. +- Tools gain one mapping target instead of defining competing task vocabularies. +- Flat identity and typed references make packets portable across storage and + presentation models. +- Deterministic events support loss detection, replay, and downstream + projections. +- Consumers still enforce cross-record rules, such as blocked work requiring + an open blocker and completed work requiring a terminal lifecycle + transition. Structural schema validation alone does not prove those set + invariants. +- The `v0` family may change while adoption reveals gaps; consumers must pin a + reviewed revision until a stable version is promoted. + +## Alternatives Considered + +### Make a project-management product the canonical home + +Rejected. A runtime may implement the contract, but portable validation must +not require installing that runtime or importing its product-specific fields. + +### Use one recursive task/project object + +Rejected. Combining packet and project semantics invites recursive containment, +ambiguous ownership, and product-specific hierarchy into every consumer. + +### Standardize a database or board model + +Rejected. Storage engines and UI columns have different portability and +evolution constraints from the interchange contract. + +### Keep the agreement as prose only + +Rejected. Producers and consumers need machine-readable validation for the +closed envelope, lifecycle, ownership, and reference rules. + +## Review-loop Items + +1. Whether Crucible should provide a reference validator for cross-record + journal-set invariants in addition to structural schema controls. +2. Whether a later stable version requires a first-class published-ledger + profile beyond the existing data-artifact bridge. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 650176b..c6f054d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,6 +50,7 @@ Examples: | [ADR-0005](ADR-0005-operation-record-classification.md) | Operation-Record Classification Standard | proposed | 2026-07-02 | | [ADR-0006](ADR-0006-process-run-contract.md) | Local Process Telemetry & Control as a Companion Portable Contract | proposed | 2026-07-06 | | [ADR-0007](ADR-0007-separate-documentation-and-schema-registry-origins.md) | Separate Crucible Documentation and Schema Registry Origins | proposed | 2026-08-19 | +| [ADR-0008](ADR-0008-project-work-contract.md) | Portable Project Work as a Companion Contract | proposed | 2026-08-21 | | [PDR-0001](PDR-0001-adopt-data-pipeline-principles.md) | Adopt the Data-Pipeline Engineering Principles | accepted | 2026-06-29 | | [PDR-0002](PDR-0002-worktree-per-task.md) | One git worktree per concurrent task | accepted | 2026-06-29 | | [PDR-0003](PDR-0003-role-portfolio-tiering.md) | Role portfolio tiering: core, supplemental, deprecated | accepted | 2026-06-29 | diff --git a/docs/standards/project-work-contract.md b/docs/standards/project-work-contract.md new file mode 100644 index 0000000..c5bb2e1 --- /dev/null +++ b/docs/standards/project-work-contract.md @@ -0,0 +1,215 @@ +--- +title: "Portable Project Work Contract" +description: "Source-neutral contract for ready-work packets, project state, control records, and an append-only NDJSON progress ledger" +category: "standards" +status: "draft" +version: "0.0.0" +lastUpdated: "2026-08-21" +maintainer: "core-standards" +reviewers: ["architecture", "project-management"] +approvers: ["lead-maintainer"] +tags: ["project", "task", "contract", "ledger", "interoperability"] +content_license: "CC0" +relatedDocs: + - "docs/standards/process-run-contract.md" + - "docs/standards/data-artifact-contract.md" + - "docs/standards/classifiers-framework.md" +audience: "implementers" +--- + +# Portable Project Work Contract + +This draft defines the portable contract identified by +`contract: project-work/v0`. + +The contract describes **ready work** and **project state** so a downstream +consumer can dispatch a bounded packet, inspect a project projection, append +progress as NDJSON, and honor classification without knowing which product +board or store produced the records. + +This is not a kanban product, a session checkpoint, a mission runner, an +attribution ledger, or a data-pipeline grain. Producers keep native boards +and caches. The portable contract governs the envelopes that travel between +orgs: the packet, the project projection, the control record, and the +progress-event line. + +## Design Stance + +- **Flat work items.** Packets have no parent/child tree. Dependencies are + edges. A project is a sibling object, not a parent task. +- **Semantic classes, not columns.** `lifecycle_class` is a closed set. + Board column strings are producer-local mappings. +- **One accountable owner.** `unassigned` cannot be `ready`. +- **Payload-open events.** Progress-event `data` is producer-owned; the + envelope and core kinds are closed. +- **Store-neutral ledger.** The contract line is NDJSON. Consumers may + project to Parquet, DuckDB, WASM, or a local cache. A local SQL file is + not the contract. +- **Sterile identity.** Opaque `id`, optional `scope` and `slug`. Instance + prefixes are local convention, not a schema enum. + +## Capability And Versioning + +Ready packets and project-state documents MUST carry the capability token: + +```json +{ + "capabilities": ["contract: project-work/v0"] +} +``` + +The L2 entry point is `schemas/project-work/v0/contract.json`. Consumers +resolve `contract: project-work/v0` to that manifest and load +`entry_schema` (`ready-packet.schema.json`). + +Path versioning follows ADR-0001 (`v0/` is unstable). + +## Objects + +| Object | Schema | Role | +| -------------- | ---------------------------- | ---------------------------- | +| Ready packet | `ready-packet.schema.json` | Dispatchable unit | +| Project state | `project-state.schema.json` | Inspectable projection | +| Control record | `control-record.schema.json` | Status, blocker, or decision | +| Progress event | `progress-event.schema.json` | One NDJSON ledger line | + +### Lifecycle class + +Closed set on packets, projects, and `class-changed` events: + +`draft` | `ready` | `active` | `blocked` | `review` | `done` | `cancelled` + +| Value | Dispatch? | +| ----------- | ------------------------------------------- | +| `draft` | No | +| `ready` | Yes | +| `active` | Already dispatched | +| `blocked` | No | +| `review` | No (executor claims complete; not accepted) | +| `done` | Terminal | +| `cancelled` | Terminal | + +**Dispatchable-packet rule.** On a **ready packet**, classes `ready`, +`active`, `review`, and `done` require `owner.kind` ≠ `unassigned` and at +least one acceptance criterion. `draft`, `blocked`, and `cancelled` may +have an empty `acceptance` array. This rule applies to packets only. + +**Project-state rule.** A project uses the same `lifecycle_class` vocabulary. +It does **not** carry `acceptance`. `owner` is optional. A project is an +inspectable projection, not a dispatchable packet. + +**Milestone dates.** `project-state.milestones[].target` is an ISO-8601 +full date (`YYYY-MM-DD`). The schema asserts both the pattern and +`format: date`, so impossible dates (for example `2026-99-99`) are +rejected. + +### Owner + +```json +{ "kind": "person" | "role" | "unassigned", "id": "…", "display": "…" } +``` + +`person` and `role` require `id`. When `kind` is `role`, `id` MUST match +the role-prompt slug shape: `^[a-z][a-z0-9]*$`, length 2–16 (no hyphens). +Do not catalog humans in the schema. `unassigned` MUST omit `id` and +`display`. + +`escalation.default` (packets only) is the same role-slug shape — not a +closed list of one org's seats. Instance files may use those slugs; the +schema must not require them. + +### Progress event kinds + +`created` | `class-changed` | `reopened` | `blocked` | `unblocked` + +There is no `completed` kind. Completion is `class-changed` with +`data.to` = `done`. Each event names exactly one of `packet_id` or +`project_id`. `seq` is required: monotonic per subject; it is the ledger +order key. Timestamp is not a ledger key. + +| kind | Authoritative transition | Required `data` | +| --------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `created` | Birth of the subject | `lifecycle_class` (initial class); MUST NOT carry `from` or `to` | +| `class-changed` | Any other class→class not covered below | `from`, `to` (`from` ≠ `to`; `from` not terminal; not a block/unblock); MUST NOT carry `lifecycle_class` | +| `reopened` | Terminal → open | `from` ∈ {done, cancelled}, `to` ∈ {draft, ready}; MUST NOT carry `lifecycle_class` | +| `blocked` | Open → blocked | `from` ∈ {draft, ready, active, review}, `to` = blocked; MUST NOT carry `lifecycle_class` | +| `unblocked` | blocked → open | `from` = blocked, `to` ∈ {draft, ready, active, review}; MUST NOT carry `lifecycle_class` | + +### Control records + +`kind` is `status` | `blocker` | `decision`. Status notes do not change +class and MUST NOT carry `waiting_on`, `decider`, `open`, or `affects`. +Blockers require `waiting_on` (opaque), may carry `open`, and MUST NOT +carry `decider` or `affects`. Decisions require `decider` (person or role +— never `unassigned`) and `affects` (a non-empty, unique list of the +packet or project ids the decision governs) and MUST NOT carry +`waiting_on` or `open`. Every record requires `actor` and exactly one of +`packet_id` or `project_id`. + +## Journal-set rules (consumer-enforced) + +These rules span multiple records. Structural `make check` does **not** +prove them. Consumers MUST enforce: + +1. A subject with `lifecycle_class` `blocked` has at least one control + record with `kind=blocker` and `open` not false. +2. `done` is claimed by a `class-changed` event to `done`, not by activity + prose. +3. A packet in `done` MUST NOT `depends_on` a packet that is not `done` or + `cancelled`. +4. Additional properties on governed envelopes fail closed. Event `data` + may carry extra producer keys beside the required transition fields. + Extra keys MUST NOT be reserved core transition keys of another + discriminant (`lifecycle_class` vs `from`/`to`). + +## Classifiers + +When a `classifiers` object is present on a packet, project, or published +ledger grain, `sensitivity` and `access-tier` are required (keys from the +existing classifier dimensions). `retention-lifecycle` is optional. + +`unknown` is valid only as a pair (both dimensions unknown, or neither). +`access-tier` MUST meet the minimum for `sensitivity` in +`docs/standards/access-tier-classification.md`. Inlined schema enums MUST +match `config/classifiers/dimensions/*` (the control script checks this). + +Do not attach `volume-tier`, `velocity-mode`, or `volatility` to a single +packet. Those classify a published ledger grain under the data-artifact +contract, not a task. + +Optional `priority` on a packet is `p0` | `p1` | `p2` | `p3`. It is not a +classifier dimension. + +## Relationship To Other Contracts + +- **process-run** — local process telemetry. Do not encode a packet as a + process card. +- **data-artifact** — when a progress ledger is **published** (not a private + cache), wrap it with `contract: data-artifact/v0`. Representations: + NDJSON (interchange) and optional Parquet (compact). Query + (DuckDB/WASM) is a consumer, not this contract. Hang `volume-tier` / + `velocity-mode` / `volatility` on **that artifact**, never on a single + packet. `progress-event` remains the line; data-artifact is the + published bag. Do not invent a second ledger wrapper. +- **review-journal** — relied-upon review events. Not the work ledger. +- **agent-wait** — live wait/poll. Not a packet field. + +## Out Of Scope For v0 + +Board columns, timers, nested tasks, sprint identifiers as required fields, +and product-specific wait-state. + +## Schema identity + +The contract identity is the host-less capability token +`contract: project-work/v0`. Schema `$id` values are +`contract:project-work/v0/`. Instances MUST NOT embed a schema host +as identity. + +## Publication / registry URL + +A hosted documentation or registry URL (for example +`https://schemas.3leaps.dev/project-work/v0/ready-packet.schema.json`) is +a retrieval convenience for humans and tooling. It is **not** the +capability token and **not** the schema `$id`. Same posture as +`process-run/v0`. diff --git a/schemas/project-work/v0/README.md b/schemas/project-work/v0/README.md new file mode 100644 index 0000000..379f865 --- /dev/null +++ b/schemas/project-work/v0/README.md @@ -0,0 +1,33 @@ +# Project Work Schemas v0 + +Machine-readable companion schemas for the portable project-work contract. + +The normative standard is +[`docs/standards/project-work-contract.md`](../../../docs/standards/project-work-contract.md). +These schemas provide a **structural** validation surface. Journal-set rules +(an open blocker when class is `blocked`; a `class-changed` to `done` as +evidence of completion) **MUST** be enforced by consumers. Kind +discriminants close reserved transition keys and kind-owned control +fields. The reference controls in `scripts/test-project-work-controls.sh` +cover structural examples/rejects and classifier-key alignment only. +`make check` does **not** prove blocked↔blocker or done↔event. + +The contract identity is the opaque capability token +`contract: project-work/v0`. Consumers resolve that token through local +configuration, a vendored copy, or another trusted registry. Instances must +not embed a schema host as their identity. + +The L2 contract entry point is `contract.json`. Consumers resolve the +capability to that manifest, verify its `capability`, and load the relative +`entry_schema` (`ready-packet.schema.json` — the packet is the dispatchable +root). Resolution fails closed when the manifest is missing, the capability +does not match, or the entry schema is missing. + +| Schema | Governs | +| ---------------------------- | ---------------------------------------------------------------------- | +| `ready-packet.schema.json` | Dispatchable unit of work | +| `project-state.schema.json` | Inspectable project projection (sibling of packets, not a parent task) | +| `control-record.schema.json` | Status note, blocker, or decision | +| `progress-event.schema.json` | One NDJSON line of the append-only work ledger | + +Worked examples in [`examples/`](examples/). Negative fixtures in [`rejects/`](rejects/). diff --git a/schemas/project-work/v0/contract.json b/schemas/project-work/v0/contract.json new file mode 100644 index 0000000..eb4e7ad --- /dev/null +++ b/schemas/project-work/v0/contract.json @@ -0,0 +1,4 @@ +{ + "capability": "contract: project-work/v0", + "entry_schema": "ready-packet.schema.json" +} diff --git a/schemas/project-work/v0/control-record.schema.json b/schemas/project-work/v0/control-record.schema.json new file mode 100644 index 0000000..6aec85c --- /dev/null +++ b/schemas/project-work/v0/control-record.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "contract:project-work/v0/control-record.schema.json", + "title": "Control Record", + "description": "Structural schema companion for contract: project-work/v0. One status note, blocker, or decision. Kind closes kind-owned fields: decisions own decider and affects (non-empty, unique); status and blocker forbid them. Class changes are progress events, not status notes. Subject is typed: exactly one of packet_id or project_id. Journal-set rules (blocked requires an open blocker) are consumer-enforced, not proven by structural make check.", + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "summary", "ts", "actor"], + "oneOf": [{ "required": ["packet_id"] }, { "required": ["project_id"] }], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "packet_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "kind": { "enum": ["status", "blocker", "decision"] }, + "summary": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "ts": { "type": "string", "format": "date-time" }, + "actor": { "$ref": "#/$defs/actor" }, + "open": { + "type": "boolean", + "description": "For blocker records: whether the blocker still applies. Default true when omitted." + }, + "waiting_on": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Opaque description of the missing fact, person, or decision. Not a chat wait-state." + }, + "decider": { "$ref": "#/$defs/actor" }, + "affects": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 128 }, + "minItems": 1, + "uniqueItems": true, + "description": "For decision records: the packet or project ids this decision governs. Decision-owned; forbidden on status and blocker records." + } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "status" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "waiting_on": false, + "decider": false, + "open": false, + "affects": false + } + } + }, + { + "if": { + "properties": { "kind": { "const": "blocker" } }, + "required": ["kind"] + }, + "then": { + "required": ["waiting_on"], + "properties": { + "decider": false, + "affects": false + } + } + }, + { + "if": { + "properties": { "kind": { "const": "decision" } }, + "required": ["kind"] + }, + "then": { + "required": ["decider", "affects"], + "properties": { + "waiting_on": false, + "open": false + } + } + } + ], + "$defs": { + "actor": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id"], + "properties": { + "kind": { "enum": ["person", "role"] }, + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "display": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "role" } }, + "required": ["kind"] + }, + "then": { + "properties": { "id": { "$ref": "#/$defs/role-slug" } } + } + } + ] + }, + "role-slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*$", + "minLength": 2, + "maxLength": 16, + "description": "Exact role-prompt slug shape (no hyphens)." + } + } +} diff --git a/schemas/project-work/v0/examples/control-record.blocker.example.json b/schemas/project-work/v0/examples/control-record.blocker.example.json new file mode 100644 index 0000000..78017e2 --- /dev/null +++ b/schemas/project-work/v0/examples/control-record.blocker.example.json @@ -0,0 +1,10 @@ +{ + "id": "pw-blocker-001", + "packet_id": "pw-ready-packet-001", + "kind": "blocker", + "summary": "Schema brief is not yet accepted.", + "waiting_on": "maintainer-acceptance-of-schema-brief", + "open": true, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/examples/control-record.decision.example.json b/schemas/project-work/v0/examples/control-record.decision.example.json new file mode 100644 index 0000000..87874ec --- /dev/null +++ b/schemas/project-work/v0/examples/control-record.decision.example.json @@ -0,0 +1,10 @@ +{ + "id": "pw-decision-001", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "Adopt the portable project-work v0 envelope for cross-org work.", + "affects": ["pw-ready-packet-001", "pw-project-001"], + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/examples/progress-events.example.ndjson b/schemas/project-work/v0/examples/progress-events.example.ndjson new file mode 100644 index 0000000..ca11b97 --- /dev/null +++ b/schemas/project-work/v0/examples/progress-events.example.ndjson @@ -0,0 +1,2 @@ +{"ts":"2026-08-21T15:00:00Z","packet_id":"pw-ready-packet-001","kind":"created","actor":{"kind":"role","id":"projectmgr"},"seq":0,"data":{"lifecycle_class":"draft"}} +{"ts":"2026-08-21T15:10:00Z","packet_id":"pw-ready-packet-001","kind":"class-changed","actor":{"kind":"role","id":"projectmgr"},"seq":1,"data":{"from":"draft","to":"ready"}} diff --git a/schemas/project-work/v0/examples/project-state.example.json b/schemas/project-work/v0/examples/project-state.example.json new file mode 100644 index 0000000..f959094 --- /dev/null +++ b/schemas/project-work/v0/examples/project-state.example.json @@ -0,0 +1,23 @@ +{ + "id": "pw-project-001", + "capabilities": ["contract: project-work/v0"], + "slug": "portable-work-floor", + "scope": "3leaps/crucible", + "outcome": "Orgs can emit and validate ready work without a product task tool.", + "owner": { "kind": "role", "id": "projectmgr" }, + "lifecycle_class": "active", + "milestones": [ + { + "id": "ms-floor", + "title": "v0 schemas in the public catalog", + "lifecycle_class": "review" + } + ], + "critical_path": ["pw-ready-packet-001"], + "forecast": { + "narrative": "Floor schemas first; product overlays after the contract exists to pin.", + "as_of": "2026-08-21T15:30:00Z", + "confidence": "medium" + }, + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/examples/ready-packet.example.json b/schemas/project-work/v0/examples/ready-packet.example.json new file mode 100644 index 0000000..3ec4a23 --- /dev/null +++ b/schemas/project-work/v0/examples/ready-packet.example.json @@ -0,0 +1,18 @@ +{ + "id": "pw-ready-packet-001", + "capabilities": ["contract: project-work/v0"], + "slug": "role-prompt-bounded-authority", + "scope": "3leaps/crucible", + "outcome": "Canonical role schema accepts bounded-authority metadata.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "ready", + "priority": "p1", + "acceptance": [ + "Canonical roles validate", + "Missing required tier fails", + "Malformed authority fails" + ], + "escalation": { "default": "entarch" }, + "depends_on": [{ "id": "schema-brief-accepted", "kind": "decision" }], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/progress-event.schema.json b/schemas/project-work/v0/progress-event.schema.json new file mode 100644 index 0000000..ee2a018 --- /dev/null +++ b/schemas/project-work/v0/progress-event.schema.json @@ -0,0 +1,238 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "contract:project-work/v0/progress-event.schema.json", + "title": "Progress Event", + "description": "Structural schema companion for contract: project-work/v0. One NDJSON line of the append-only work ledger. Envelope is contract-governed; extra keys on data are producer-owned except reserved core transition keys, which are reserved by kind. Kinds are locked; each kind has authoritative transition semantics so a consumer can project lifecycle state. seq is the per-subject order key (timestamp is not).", + "type": "object", + "additionalProperties": false, + "required": ["ts", "kind", "actor", "seq", "data"], + "oneOf": [{ "required": ["packet_id"] }, { "required": ["project_id"] }], + "properties": { + "ts": { "type": "string", "format": "date-time" }, + "packet_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "project_id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "kind": { + "enum": ["created", "class-changed", "reopened", "blocked", "unblocked"] + }, + "actor": { "$ref": "#/$defs/actor" }, + "seq": { + "type": "integer", + "minimum": 0, + "description": "Monotonic per-subject sequence. The ledger order key. Gaps indicate loss; regression indicates corruption." + }, + "data": { + "type": "object", + "description": "Transition payload. Core from/to and lifecycle_class keys are contract-governed and reserved by kind; additional properties are producer-owned." + } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "created" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["lifecycle_class"], + "properties": { + "lifecycle_class": { "$ref": "#/$defs/lifecycle-class" }, + "from": false, + "to": false + } + } + } + } + }, + { + "if": { + "properties": { "kind": { "const": "class-changed" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "$ref": "#/$defs/lifecycle-class" }, + "to": { "$ref": "#/$defs/lifecycle-class" }, + "lifecycle_class": false + }, + "allOf": [ + { + "not": { + "properties": { "to": { "const": "blocked" } }, + "required": ["to"] + } + }, + { + "not": { + "properties": { "from": { "const": "blocked" } }, + "required": ["from"] + } + }, + { + "not": { + "properties": { + "from": { "enum": ["done", "cancelled"] } + }, + "required": ["from"] + } + }, + { + "if": { + "properties": { "from": { "const": "draft" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "draft" } } } + } + }, + { + "if": { + "properties": { "from": { "const": "ready" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "ready" } } } + } + }, + { + "if": { + "properties": { "from": { "const": "active" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "active" } } } + } + }, + { + "if": { + "properties": { "from": { "const": "review" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "review" } } } + } + }, + { + "if": { + "properties": { "from": { "const": "done" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "done" } } } + } + }, + { + "if": { + "properties": { "from": { "const": "cancelled" } }, + "required": ["from"] + }, + "then": { + "properties": { "to": { "not": { "const": "cancelled" } } } + } + } + ] + } + } + } + }, + { + "if": { + "properties": { "kind": { "const": "reopened" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "enum": ["done", "cancelled"] }, + "to": { "enum": ["draft", "ready"] }, + "lifecycle_class": false + } + } + } + } + }, + { + "if": { + "properties": { "kind": { "const": "blocked" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { + "enum": ["draft", "ready", "active", "review"] + }, + "to": { "const": "blocked" }, + "lifecycle_class": false + } + } + } + } + }, + { + "if": { + "properties": { "kind": { "const": "unblocked" } }, + "required": ["kind"] + }, + "then": { + "properties": { + "data": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "const": "blocked" }, + "to": { + "enum": ["draft", "ready", "active", "review"] + }, + "lifecycle_class": false + } + } + } + } + } + ], + "$defs": { + "lifecycle-class": { + "enum": ["draft", "ready", "active", "blocked", "review", "done", "cancelled"] + }, + "actor": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id"], + "properties": { + "kind": { "enum": ["person", "role"] }, + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "display": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "const": "role" } }, + "required": ["kind"] + }, + "then": { + "properties": { "id": { "$ref": "#/$defs/role-slug" } } + } + } + ] + }, + "role-slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*$", + "minLength": 2, + "maxLength": 16, + "description": "Exact role-prompt slug shape (no hyphens)." + } + } +} diff --git a/schemas/project-work/v0/project-state.schema.json b/schemas/project-work/v0/project-state.schema.json new file mode 100644 index 0000000..bc7d62a --- /dev/null +++ b/schemas/project-work/v0/project-state.schema.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "contract:project-work/v0/project-state.schema.json", + "title": "Project State", + "description": "Structural schema companion for contract: project-work/v0. Inspectable project projection: approved outcome, lifecycle class, milestones, and critical path as packet ids. A project is a sibling of packets, not a parent task. Packets are not embedded.", + "type": "object", + "additionalProperties": false, + "required": ["capabilities", "id", "outcome", "lifecycle_class", "updated"], + "properties": { + "capabilities": { + "type": "array", + "items": { "type": "string" }, + "contains": { "const": "contract: project-work/v0" }, + "minItems": 1, + "uniqueItems": true + }, + "id": { "$ref": "#/$defs/opaque-id" }, + "scope": { "$ref": "#/$defs/scope-path" }, + "slug": { "$ref": "#/$defs/display-slug" }, + "outcome": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "owner": { "$ref": "#/$defs/owner" }, + "lifecycle_class": { "$ref": "#/$defs/lifecycle-class" }, + "milestones": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "lifecycle_class"], + "properties": { + "id": { "$ref": "#/$defs/opaque-id" }, + "title": { "type": "string", "minLength": 1, "maxLength": 256 }, + "target": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "format": "date" + }, + "lifecycle_class": { "$ref": "#/$defs/lifecycle-class" } + } + } + }, + "critical_path": { + "type": "array", + "items": { "$ref": "#/$defs/opaque-id" }, + "description": "Ordered packet ids. Does not embed packets." + }, + "forecast": { + "type": "object", + "additionalProperties": false, + "required": ["narrative", "as_of", "confidence"], + "properties": { + "narrative": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "as_of": { "type": "string", "format": "date-time" }, + "confidence": { "enum": ["low", "medium", "high"] } + } + }, + "classifiers": { "$ref": "#/$defs/classifiers" }, + "updated": { "type": "string", "format": "date-time" } + }, + "$defs": { + "opaque-id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope-path": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(/[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "maxLength": 253 + }, + "display-slug": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 63 + }, + "lifecycle-class": { + "enum": ["draft", "ready", "active", "blocked", "review", "done", "cancelled"] + }, + "role-slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*$", + "minLength": 2, + "maxLength": 16, + "description": "Exact role-prompt slug shape (no hyphens)." + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "enum": ["person", "role", "unassigned"] }, + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "display": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "enum": ["person", "role"] } }, + "required": ["kind"] + }, + "then": { "required": ["id"] } + }, + { + "if": { + "properties": { "kind": { "const": "role" } }, + "required": ["kind"] + }, + "then": { + "properties": { "id": { "$ref": "#/$defs/role-slug" } } + } + }, + { + "if": { + "properties": { "kind": { "const": "unassigned" } }, + "required": ["kind"] + }, + "then": { + "not": { + "anyOf": [{ "required": ["id"] }, { "required": ["display"] }] + } + } + } + ] + }, + "classifiers": { + "type": "object", + "additionalProperties": false, + "required": ["sensitivity", "access-tier"], + "description": "Reuse of catalog dimensions. Cross-enum floor access-tier >= sensitivity. unknown is valid only as a pair.", + "properties": { + "sensitivity": { + "enum": [ + "unknown", + "0-public", + "1-confidential", + "2-blinded", + "3-proprietary", + "4-personal", + "5-privileged", + "6-eyes-only" + ] + }, + "access-tier": { + "enum": ["unknown", "public", "internal", "restricted", "privileged", "eyes-only"] + }, + "retention-lifecycle": { + "enum": ["unknown", "transient", "short", "standard", "long", "legal-hold"] + } + }, + "allOf": [ + { + "if": { + "properties": { "sensitivity": { "const": "unknown" } } + }, + "then": { + "properties": { "access-tier": { "const": "unknown" } } + } + }, + { + "if": { + "properties": { "access-tier": { "const": "unknown" } } + }, + "then": { + "properties": { "sensitivity": { "const": "unknown" } } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "0-public" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["public", "internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "1-confidential" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "2-blinded" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "3-proprietary" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "4-personal" } } + }, + "then": { + "properties": { + "access-tier": { "enum": ["privileged", "eyes-only"] } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "5-privileged" } } + }, + "then": { + "properties": { + "access-tier": { "enum": ["privileged", "eyes-only"] } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "6-eyes-only" } } + }, + "then": { + "properties": { "access-tier": { "const": "eyes-only" } } + } + } + ] + } + } +} diff --git a/schemas/project-work/v0/ready-packet.schema.json b/schemas/project-work/v0/ready-packet.schema.json new file mode 100644 index 0000000..5fbbdef --- /dev/null +++ b/schemas/project-work/v0/ready-packet.schema.json @@ -0,0 +1,306 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "contract:project-work/v0/ready-packet.schema.json", + "title": "Ready Packet", + "description": "Structural schema companion for contract: project-work/v0. The ready packet is the dispatchable unit of work: outcome, owner, lifecycle class, and acceptance. Hierarchy is forbidden. Journal-set rules are consumer-enforced.", + "type": "object", + "additionalProperties": false, + "required": [ + "capabilities", + "id", + "outcome", + "owner", + "lifecycle_class", + "acceptance", + "updated" + ], + "properties": { + "capabilities": { + "type": "array", + "description": "Host-less capability tokens. Must include the exact opaque project-work contract token.", + "items": { "type": "string" }, + "contains": { "const": "contract: project-work/v0" }, + "minItems": 1, + "uniqueItems": true + }, + "id": { "$ref": "#/$defs/opaque-id" }, + "scope": { "$ref": "#/$defs/scope-path" }, + "slug": { "$ref": "#/$defs/display-slug" }, + "project_id": { "$ref": "#/$defs/opaque-id" }, + "milestone_id": { "$ref": "#/$defs/opaque-id" }, + "outcome": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "The desired result in one breath. Completion is recognized against this plus acceptance." + }, + "boundary": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "What is out of scope." + }, + "owner": { "$ref": "#/$defs/owner" }, + "lifecycle_class": { "$ref": "#/$defs/lifecycle-class" }, + "acceptance": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "description": "Checkable completion criteria. Required (min 1) on dispatchable classes ready, active, review, and done. Empty is allowed on draft, blocked, and cancelled." + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ref"], + "properties": { + "ref": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "note": { "type": "string", "minLength": 1, "maxLength": 1024 } + } + } + }, + "depends_on": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind"], + "properties": { + "id": { "$ref": "#/$defs/opaque-id" }, + "kind": { "enum": ["packet", "project", "decision", "external"] } + } + } + }, + "escalation": { + "type": "object", + "additionalProperties": false, + "required": ["default"], + "properties": { + "default": { + "$ref": "#/$defs/role-slug", + "description": "Accountable escalation target as a role slug. Not a closed catalog of one org's seats." + }, + "note": { "type": "string", "minLength": 1, "maxLength": 1024 } + } + }, + "priority": { "enum": ["p0", "p1", "p2", "p3"] }, + "labels": { + "type": "array", + "items": { "$ref": "#/$defs/display-slug" }, + "uniqueItems": true + }, + "classifiers": { "$ref": "#/$defs/classifiers" }, + "updated": { "type": "string", "format": "date-time" } + }, + "allOf": [ + { + "if": { + "properties": { + "lifecycle_class": { + "enum": ["ready", "active", "review", "done"] + } + }, + "required": ["lifecycle_class"] + }, + "then": { + "properties": { + "owner": { + "not": { + "properties": { "kind": { "const": "unassigned" } }, + "required": ["kind"] + } + }, + "acceptance": { "minItems": 1 } + } + } + } + ], + "$defs": { + "opaque-id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Opaque identity. Instance prefixes are local convention, not a closed enum." + }, + "scope-path": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(/[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$", + "maxLength": 253 + }, + "display-slug": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "maxLength": 63 + }, + "role-slug": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*$", + "minLength": 2, + "maxLength": 16, + "description": "Exact role-prompt slug shape (lowercase letters/numbers, no hyphens, 2–16)." + }, + "lifecycle-class": { + "enum": ["draft", "ready", "active", "blocked", "review", "done", "cancelled"], + "description": "Semantic class, not a board column." + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "enum": ["person", "role", "unassigned"] }, + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "display": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "allOf": [ + { + "if": { + "properties": { "kind": { "enum": ["person", "role"] } }, + "required": ["kind"] + }, + "then": { "required": ["id"] } + }, + { + "if": { + "properties": { "kind": { "const": "role" } }, + "required": ["kind"] + }, + "then": { + "properties": { "id": { "$ref": "#/$defs/role-slug" } } + } + }, + { + "if": { + "properties": { "kind": { "const": "unassigned" } }, + "required": ["kind"] + }, + "then": { + "not": { + "anyOf": [{ "required": ["id"] }, { "required": ["display"] }] + } + } + } + ] + }, + "classifiers": { + "type": "object", + "additionalProperties": false, + "required": ["sensitivity", "access-tier"], + "description": "Reuse of catalog dimensions. Cross-enum floor access-tier >= sensitivity per docs/standards/access-tier-classification.md. unknown is valid only as a pair.", + "properties": { + "sensitivity": { + "enum": [ + "unknown", + "0-public", + "1-confidential", + "2-blinded", + "3-proprietary", + "4-personal", + "5-privileged", + "6-eyes-only" + ] + }, + "access-tier": { + "enum": ["unknown", "public", "internal", "restricted", "privileged", "eyes-only"] + }, + "retention-lifecycle": { + "enum": ["unknown", "transient", "short", "standard", "long", "legal-hold"] + } + }, + "allOf": [ + { + "if": { + "properties": { "sensitivity": { "const": "unknown" } } + }, + "then": { + "properties": { "access-tier": { "const": "unknown" } } + } + }, + { + "if": { + "properties": { "access-tier": { "const": "unknown" } } + }, + "then": { + "properties": { "sensitivity": { "const": "unknown" } } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "0-public" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["public", "internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "1-confidential" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "2-blinded" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["internal", "restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "3-proprietary" } } + }, + "then": { + "properties": { + "access-tier": { + "enum": ["restricted", "privileged", "eyes-only"] + } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "4-personal" } } + }, + "then": { + "properties": { + "access-tier": { "enum": ["privileged", "eyes-only"] } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "5-privileged" } } + }, + "then": { + "properties": { + "access-tier": { "enum": ["privileged", "eyes-only"] } + } + } + }, + { + "if": { + "properties": { "sensitivity": { "const": "6-eyes-only" } } + }, + "then": { + "properties": { "access-tier": { "const": "eyes-only" } } + } + } + ] + } + } +} diff --git a/schemas/project-work/v0/rejects/README.md b/schemas/project-work/v0/rejects/README.md new file mode 100644 index 0000000..f90d51c --- /dev/null +++ b/schemas/project-work/v0/rejects/README.md @@ -0,0 +1,53 @@ +# Rejects — project-work/v0 + +Negative fixtures, grouped by schema. Each `reject-*.json` must fail the +schema of its directory. + +## ready-packet + +| File | Why | +| ------------------------------------- | ------------------------------------------------------------- | +| `reject-nested-children.json` | Hierarchy is forbidden. | +| `reject-lifecycle-todo.json` | `todo` is not a lifecycle class. | +| `reject-unassigned-ready.json` | `unassigned` owner cannot be `ready`. | +| `reject-unassigned-with-id.json` | `unassigned` must omit `id` and `display`. | +| `reject-escalation-not-slug.json` | `escalation.default` is a role slug, not an estate seat enum. | +| `reject-role-id-hyphen.json` | Role `id` must match role-prompt slug (no hyphens). | +| `reject-classifier-half-unknown.json` | `unknown` is valid only as a pair. | +| `reject-classifier-floor.json` | `access-tier` below the sensitivity floor. | + +## progress-event + +| File | Why | +| ------------------------------------------------ | --------------------------------------------------- | +| `reject-missing-seq.json` | `seq` is the ledger order key. | +| `reject-class-changed-no-to.json` | `class-changed` requires `from` and `to`. | +| `reject-blocked-wrong-to.json` | `blocked` requires `to=blocked`. | +| `reject-dual-subject.json` | Exactly one of `packet_id` or `project_id`. | +| `reject-created-no-class.json` | `created` requires initial `lifecycle_class`. | +| `reject-created-with-from-to.json` | `created` forbids contract `from`/`to`. | +| `reject-class-changed-from-done.json` | Terminal exits are `reopened`, not `class-changed`. | +| `reject-class-changed-with-lifecycle-class.json` | Transition kinds forbid contract `lifecycle_class`. | + +## control-record + +| File | Why | +| ---------------------------------------- | ------------------------------------------- | +| `reject-dual-subject.json` | Exactly one of `packet_id` or `project_id`. | +| `reject-missing-actor.json` | Records must be attributable. | +| `reject-unassigned-decider.json` | A decision `decider` is a person or role. | +| `reject-untyped-subject.json` | Bare `subject_id` is not a typed subject. | +| `reject-status-cross-kind.json` | Status forbids blocker and decision fields. | +| `reject-blocker-with-decider.json` | Blockers forbid `decider`. | +| `reject-decision-with-waiting-on.json` | Decisions forbid blocker-only fields. | +| `reject-decision-missing-affects.json` | A decision requires `affects`. | +| `reject-decision-empty-affects.json` | Decision `affects` must be non-empty. | +| `reject-decision-duplicate-affects.json` | Decision `affects` must be unique. | +| `reject-status-with-affects.json` | Status forbids decision-owned `affects`. | +| `reject-blocker-with-affects.json` | Blockers forbid decision-owned `affects`. | + +## project-state + +| File | Why | +| --------------------------------------- | ------------------------------------------------ | +| `reject-milestone-impossible-date.json` | Milestone `target` must be a real ISO-8601 date. | diff --git a/schemas/project-work/v0/rejects/control-record/reject-blocker-with-affects.json b/schemas/project-work/v0/rejects/control-record/reject-blocker-with-affects.json new file mode 100644 index 0000000..6c068e4 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-blocker-with-affects.json @@ -0,0 +1,11 @@ +{ + "id": "pw-blocker-with-affects", + "packet_id": "pw-ready-packet-001", + "kind": "blocker", + "summary": "Blockers must not carry decision-owned affects.", + "waiting_on": "schema-brief", + "open": true, + "affects": ["pw-ready-packet-001"], + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-blocker-with-decider.json b/schemas/project-work/v0/rejects/control-record/reject-blocker-with-decider.json new file mode 100644 index 0000000..2c93912 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-blocker-with-decider.json @@ -0,0 +1,11 @@ +{ + "id": "pw-blocker-with-decider", + "packet_id": "pw-ready-packet-001", + "kind": "blocker", + "summary": "Blockers must not carry a decision decider.", + "waiting_on": "schema-brief", + "open": true, + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-decision-duplicate-affects.json b/schemas/project-work/v0/rejects/control-record/reject-decision-duplicate-affects.json new file mode 100644 index 0000000..fdcb8ca --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-decision-duplicate-affects.json @@ -0,0 +1,10 @@ +{ + "id": "pw-decision-duplicate-affects", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "Decision affects must be unique.", + "affects": ["pw-ready-packet-001", "pw-ready-packet-001"], + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-decision-empty-affects.json b/schemas/project-work/v0/rejects/control-record/reject-decision-empty-affects.json new file mode 100644 index 0000000..9d47d55 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-decision-empty-affects.json @@ -0,0 +1,10 @@ +{ + "id": "pw-decision-empty-affects", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "An empty affects list is not a valid decision.", + "affects": [], + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-decision-missing-affects.json b/schemas/project-work/v0/rejects/control-record/reject-decision-missing-affects.json new file mode 100644 index 0000000..6ff75ee --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-decision-missing-affects.json @@ -0,0 +1,9 @@ +{ + "id": "pw-decision-missing-affects", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "A decision requires affects.", + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-decision-with-waiting-on.json b/schemas/project-work/v0/rejects/control-record/reject-decision-with-waiting-on.json new file mode 100644 index 0000000..96638c2 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-decision-with-waiting-on.json @@ -0,0 +1,10 @@ +{ + "id": "pw-decision-with-waiting-on", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "Decisions must not carry blocker-only fields.", + "waiting_on": "a-blocker", + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-dual-subject.json b/schemas/project-work/v0/rejects/control-record/reject-dual-subject.json new file mode 100644 index 0000000..50df958 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-dual-subject.json @@ -0,0 +1,10 @@ +{ + "id": "pw-blocker-dual", + "packet_id": "pw-ready-packet-001", + "project_id": "pw-project-001", + "kind": "blocker", + "summary": "Cannot attach to both subjects.", + "waiting_on": "one-subject", + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-missing-actor.json b/schemas/project-work/v0/rejects/control-record/reject-missing-actor.json new file mode 100644 index 0000000..4bc9b5e --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-missing-actor.json @@ -0,0 +1,8 @@ +{ + "id": "pw-blocker-no-actor", + "packet_id": "pw-ready-packet-001", + "kind": "blocker", + "summary": "Must be attributable.", + "waiting_on": "an-actor", + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-status-cross-kind.json b/schemas/project-work/v0/rejects/control-record/reject-status-cross-kind.json new file mode 100644 index 0000000..e94d217 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-status-cross-kind.json @@ -0,0 +1,11 @@ +{ + "id": "pw-status-cross-kind", + "packet_id": "pw-ready-packet-001", + "kind": "status", + "summary": "Status notes must not carry blocker or decision fields.", + "open": false, + "waiting_on": "a-blocker", + "decider": { "kind": "role", "id": "entarch" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-status-with-affects.json b/schemas/project-work/v0/rejects/control-record/reject-status-with-affects.json new file mode 100644 index 0000000..67b3a6a --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-status-with-affects.json @@ -0,0 +1,9 @@ +{ + "id": "pw-status-with-affects", + "packet_id": "pw-ready-packet-001", + "kind": "status", + "summary": "Status notes must not carry decision-owned affects.", + "affects": ["pw-ready-packet-001"], + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-unassigned-decider.json b/schemas/project-work/v0/rejects/control-record/reject-unassigned-decider.json new file mode 100644 index 0000000..e576080 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-unassigned-decider.json @@ -0,0 +1,9 @@ +{ + "id": "pw-decision-unassigned", + "packet_id": "pw-ready-packet-001", + "kind": "decision", + "summary": "Decider must be a person or role.", + "decider": { "kind": "unassigned" }, + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/control-record/reject-untyped-subject.json b/schemas/project-work/v0/rejects/control-record/reject-untyped-subject.json new file mode 100644 index 0000000..68841f4 --- /dev/null +++ b/schemas/project-work/v0/rejects/control-record/reject-untyped-subject.json @@ -0,0 +1,9 @@ +{ + "id": "pw-blocker-untyped", + "subject_id": "pw-ready-packet-001", + "kind": "blocker", + "summary": "Subject must be packet_id or project_id.", + "waiting_on": "typed-subject", + "actor": { "kind": "role", "id": "projectmgr" }, + "ts": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-blocked-wrong-to.json b/schemas/project-work/v0/rejects/progress-event/reject-blocked-wrong-to.json new file mode 100644 index 0000000..d51cbab --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-blocked-wrong-to.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T15:12:00Z", + "packet_id": "pw-ready-packet-001", + "kind": "blocked", + "actor": { "kind": "role", "id": "projectmgr" }, + "seq": 2, + "data": { "from": "ready", "to": "review" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-class-changed-from-done.json b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-from-done.json new file mode 100644 index 0000000..33be881 --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-from-done.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T16:00:00Z", + "packet_id": "pw-ready-packet-001", + "kind": "class-changed", + "actor": { "kind": "role", "id": "projectmgr" }, + "seq": 3, + "data": { "from": "done", "to": "active" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-class-changed-no-to.json b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-no-to.json new file mode 100644 index 0000000..6dc145a --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-no-to.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T15:10:00Z", + "packet_id": "pw-ready-packet-001", + "kind": "class-changed", + "actor": { "kind": "role", "id": "projectmgr" }, + "seq": 1, + "data": { "from": "draft" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-class-changed-with-lifecycle-class.json b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-with-lifecycle-class.json new file mode 100644 index 0000000..138f634 --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-class-changed-with-lifecycle-class.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T20:00:00Z", + "packet_id": "pw-test", + "kind": "class-changed", + "actor": { "kind": "role", "id": "devrev" }, + "seq": 1, + "data": { "from": "draft", "to": "ready", "lifecycle_class": "cancelled" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-created-no-class.json b/schemas/project-work/v0/rejects/progress-event/reject-created-no-class.json new file mode 100644 index 0000000..1eeb35a --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-created-no-class.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T15:00:00Z", + "packet_id": "pw-ready-packet-001", + "kind": "created", + "actor": { "kind": "role", "id": "projectmgr" }, + "seq": 0, + "data": {} +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-created-with-from-to.json b/schemas/project-work/v0/rejects/progress-event/reject-created-with-from-to.json new file mode 100644 index 0000000..f2c17e8 --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-created-with-from-to.json @@ -0,0 +1,8 @@ +{ + "ts": "2026-08-21T20:00:00Z", + "packet_id": "pw-test", + "kind": "created", + "actor": { "kind": "role", "id": "devrev" }, + "seq": 0, + "data": { "lifecycle_class": "draft", "from": "done", "to": "done" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-dual-subject.json b/schemas/project-work/v0/rejects/progress-event/reject-dual-subject.json new file mode 100644 index 0000000..86580a2 --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-dual-subject.json @@ -0,0 +1,9 @@ +{ + "ts": "2026-08-21T15:00:00Z", + "packet_id": "pw-ready-packet-001", + "project_id": "pw-project-001", + "kind": "created", + "actor": { "kind": "role", "id": "projectmgr" }, + "seq": 0, + "data": { "lifecycle_class": "draft" } +} diff --git a/schemas/project-work/v0/rejects/progress-event/reject-missing-seq.json b/schemas/project-work/v0/rejects/progress-event/reject-missing-seq.json new file mode 100644 index 0000000..ea78a64 --- /dev/null +++ b/schemas/project-work/v0/rejects/progress-event/reject-missing-seq.json @@ -0,0 +1,7 @@ +{ + "ts": "2026-08-21T15:00:00Z", + "packet_id": "pw-ready-packet-001", + "kind": "created", + "actor": { "kind": "role", "id": "projectmgr" }, + "data": { "lifecycle_class": "draft" } +} diff --git a/schemas/project-work/v0/rejects/project-state/reject-milestone-impossible-date.json b/schemas/project-work/v0/rejects/project-state/reject-milestone-impossible-date.json new file mode 100644 index 0000000..bd9f294 --- /dev/null +++ b/schemas/project-work/v0/rejects/project-state/reject-milestone-impossible-date.json @@ -0,0 +1,24 @@ +{ + "id": "pw-project-impossible-date", + "capabilities": ["contract: project-work/v0"], + "slug": "portable-work-floor", + "scope": "3leaps/crucible", + "outcome": "Milestone target date must be a real calendar date.", + "owner": { "kind": "role", "id": "projectmgr" }, + "lifecycle_class": "active", + "milestones": [ + { + "id": "ms-impossible", + "title": "Impossible target date", + "target": "2026-99-99", + "lifecycle_class": "review" + } + ], + "critical_path": ["pw-ready-packet-001"], + "forecast": { + "narrative": "Impossible milestone dates fail format: date.", + "as_of": "2026-08-24T14:00:00Z", + "confidence": "high" + }, + "updated": "2026-08-24T14:00:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-classifier-floor.json b/schemas/project-work/v0/rejects/ready-packet/reject-classifier-floor.json new file mode 100644 index 0000000..0bb5dc9 --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-classifier-floor.json @@ -0,0 +1,13 @@ +{ + "id": "pw-bad-classifier-floor", + "capabilities": ["contract: project-work/v0"], + "outcome": "access-tier must meet the sensitivity floor.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "ready", + "acceptance": ["Floor holds"], + "classifiers": { + "sensitivity": "3-proprietary", + "access-tier": "public" + }, + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-classifier-half-unknown.json b/schemas/project-work/v0/rejects/ready-packet/reject-classifier-half-unknown.json new file mode 100644 index 0000000..190081e --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-classifier-half-unknown.json @@ -0,0 +1,13 @@ +{ + "id": "pw-bad-half-unknown", + "capabilities": ["contract: project-work/v0"], + "outcome": "unknown classifiers must be a pair.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "ready", + "acceptance": ["Pair unknown"], + "classifiers": { + "sensitivity": "3-proprietary", + "access-tier": "unknown" + }, + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-escalation-not-slug.json b/schemas/project-work/v0/rejects/ready-packet/reject-escalation-not-slug.json new file mode 100644 index 0000000..d6061d8 --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-escalation-not-slug.json @@ -0,0 +1,10 @@ +{ + "id": "pw-bad-escalation", + "capabilities": ["contract: project-work/v0"], + "outcome": "Escalation default is a portable role slug.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "ready", + "acceptance": ["Portable slug"], + "escalation": { "default": "DISPATCH" }, + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-lifecycle-todo.json b/schemas/project-work/v0/rejects/ready-packet/reject-lifecycle-todo.json new file mode 100644 index 0000000..e5ef289 --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-lifecycle-todo.json @@ -0,0 +1,9 @@ +{ + "id": "pw-bad-class", + "capabilities": ["contract: project-work/v0"], + "outcome": "Must use a closed lifecycle class.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "todo", + "acceptance": ["Use ready not todo"], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-nested-children.json b/schemas/project-work/v0/rejects/ready-packet/reject-nested-children.json new file mode 100644 index 0000000..9ba7eb1 --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-nested-children.json @@ -0,0 +1,10 @@ +{ + "id": "pw-bad-tree", + "capabilities": ["contract: project-work/v0"], + "outcome": "Must not nest tasks.", + "owner": { "kind": "role", "id": "devlead" }, + "lifecycle_class": "ready", + "acceptance": ["No children field"], + "children": ["pw-child-1"], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-role-id-hyphen.json b/schemas/project-work/v0/rejects/ready-packet/reject-role-id-hyphen.json new file mode 100644 index 0000000..9f11317 --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-role-id-hyphen.json @@ -0,0 +1,9 @@ +{ + "id": "pw-bad-role-hyphen", + "capabilities": ["contract: project-work/v0"], + "outcome": "Role ids must match the role-prompt slug shape.", + "owner": { "kind": "role", "id": "dev-lead" }, + "lifecycle_class": "ready", + "acceptance": ["No hyphens in role slugs"], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-ready.json b/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-ready.json new file mode 100644 index 0000000..4cb79cc --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-ready.json @@ -0,0 +1,9 @@ +{ + "id": "pw-bad-owner", + "capabilities": ["contract: project-work/v0"], + "outcome": "Ready work must have an accountable owner.", + "owner": { "kind": "unassigned" }, + "lifecycle_class": "ready", + "acceptance": ["Owner is named"], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-with-id.json b/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-with-id.json new file mode 100644 index 0000000..ed7252d --- /dev/null +++ b/schemas/project-work/v0/rejects/ready-packet/reject-unassigned-with-id.json @@ -0,0 +1,9 @@ +{ + "id": "pw-bad-unassigned-id", + "capabilities": ["contract: project-work/v0"], + "outcome": "Unassigned owner must omit id and display.", + "owner": { "kind": "unassigned", "id": "nobody" }, + "lifecycle_class": "draft", + "acceptance": [], + "updated": "2026-08-21T15:30:00Z" +} diff --git a/scripts/test-project-work-controls.sh b/scripts/test-project-work-controls.sh new file mode 100755 index 0000000..0642faf --- /dev/null +++ b/scripts/test-project-work-controls.sh @@ -0,0 +1,84 @@ +#!/bin/sh + +set -eu + +packet_schema="schemas/project-work/v0/ready-packet.schema.json" +project_schema="schemas/project-work/v0/project-state.schema.json" +control_schema="schemas/project-work/v0/control-record.schema.json" +event_schema="schemas/project-work/v0/progress-event.schema.json" + +goneat validate data --schema-file "$packet_schema" --data schemas/project-work/v0/examples/ready-packet.example.json >/dev/null +echo " [ok] project-work ready-packet example passes" + +goneat validate data --schema-file "$project_schema" --data schemas/project-work/v0/examples/project-state.example.json >/dev/null +echo " [ok] project-work project-state example passes" + +goneat validate data --schema-file "$control_schema" --data schemas/project-work/v0/examples/control-record.blocker.example.json >/dev/null +echo " [ok] project-work control-record example passes" + +goneat validate data --schema-file "$control_schema" --data schemas/project-work/v0/examples/control-record.decision.example.json >/dev/null +echo " [ok] project-work control-record decision example passes" + +tmpd=$(mktemp -d) +tmp="$tmpd/event.json" +while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\n' "$line" >"$tmp" + goneat validate data --schema-file "$event_schema" --data "$tmp" >/dev/null +done /dev/null 2>&1; then + echo " [!!] project-work reject unexpectedly passed: $candidate" + exit 1 + fi + echo " [ok] project-work rejected: $candidate" + done +} + +reject_dir "$packet_schema" schemas/project-work/v0/rejects/ready-packet +reject_dir "$event_schema" schemas/project-work/v0/rejects/progress-event +reject_dir "$control_schema" schemas/project-work/v0/rejects/control-record +reject_dir "$project_schema" schemas/project-work/v0/rejects/project-state + +# Inlined classifier keys must match catalog dimensions (anti-fork). +dim_keys() { + jq -r '.categorical.values[].key' "$1" | sort | tr '\n' ' ' +} + +schema_enum() { + jq -r --arg field "$2" '."$defs".classifiers.properties[$field].enum[]' "$1" | sort | tr '\n' ' ' +} + +sens_dim=$(dim_keys config/classifiers/dimensions/sensitivity.dimension.json) +tier_dim=$(dim_keys config/classifiers/dimensions/access-tier.dimension.json) +ret_dim=$(dim_keys config/classifiers/dimensions/retention-lifecycle.dimension.json) + +for schema_file in "$packet_schema" "$project_schema"; do + sens_schema=$(schema_enum "$schema_file" sensitivity) + tier_schema=$(schema_enum "$schema_file" "access-tier") + ret_schema=$(schema_enum "$schema_file" "retention-lifecycle") + if [ "$sens_dim" != "$sens_schema" ]; then + echo " [!!] sensitivity enum drifted in $schema_file" >&2 + echo " dim: $sens_dim" >&2 + echo " schema: $sens_schema" >&2 + exit 1 + fi + if [ "$tier_dim" != "$tier_schema" ]; then + echo " [!!] access-tier enum drifted in $schema_file" >&2 + exit 1 + fi + if [ "$ret_dim" != "$ret_schema" ]; then + echo " [!!] retention-lifecycle enum drifted in $schema_file" >&2 + exit 1 + fi +done +echo " [ok] project-work classifier keys match catalog dimensions (packet + project)" + +echo " [ok] project-work controls passed"