From 9c5e9444064e6f7a9d9bd2f9e26ebd57c92c882b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:36:19 +0200 Subject: [PATCH 1/8] =?UTF-8?q?docs(vrs):=20resolve=20DQ04=20=E2=80=94=20a?= =?UTF-8?q?=20Plan's=20identity=20is=20its=20origin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision 0017: PlanRef = content hash of the origin (predecessor-less) version. Fully derived (CMP-R10), encodes no location (CMP.DM-R08), cannot collide, and makes 'same Plan' a content fact — two versions share a Plan iff they share an origin, and the origin's own identity is the PlanRef. The required goal becomes the human handle (CMP.DM-R12), so identity need not be readable and stays purely derived — machine identity and human readability each keep their own job. A Plan is never renamed because it was never named. Fixes the real hole: the impl used the catalog directory as the handle, making identity a path (violating CMP.DM-R08); a misfiled version now resolves its Plan from its origin and is rejected on mismatch, not reinterpreted. The CMP-R11 objection (a hash is unavailable before the origin exists) does not bind: authoring references no PlanRef, so starting stays one command. Updates ontology, 01-data-model (CMP.DM-R11/R12), 02-artifacts (dir is the PlanRef), 06-api, 04-cli; DQ04 marked resolved. --- .../0017-a-plans-identity-is-its-origin.md | 90 +++++++++++++++++++ context/01-data-model/requirements.md | 15 ++++ context/01-data-model/spec.md | 5 +- context/02-artifacts/spec.md | 11 ++- context/04-cli/spec.md | 6 ++ context/06-api/spec.md | 5 ++ context/ontology.md | 10 ++- context/open-questions.md | 19 ++-- 8 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 context/.decisions/0017-a-plans-identity-is-its-origin.md diff --git a/context/.decisions/0017-a-plans-identity-is-its-origin.md b/context/.decisions/0017-a-plans-identity-is-its-origin.md new file mode 100644 index 0000000..519754a --- /dev/null +++ b/context/.decisions/0017-a-plans-identity-is-its-origin.md @@ -0,0 +1,90 @@ +# A Plan's identity is its origin + +Status: accepted + +Resolves DQ04. Completes [0012](./0012-intent-is-authored-as-code-and-identity-is-declared.md), +which settled Step identity and removed the minting that had supplied Plan +references too, without saying what replaced them. + +## Context + +A Step's identity is the name it is declared under (0012). A Plan has no such +declaration site — it is not an export in another module — so that mechanism does +not transfer. Minting is gone. In its absence the implementation fell back on the +catalog directory as the Plan's handle, which makes identity a filesystem +location: exactly what the ontology and CMP.DM-R08 forbid, and it means moving or +misfiling a version silently changes which Plan it belongs to. + +Three candidates were on the table: a declared name, a path segment, and the +content hash of the first version. + +## Evidence and Argument + +The anti-minting principle (CMP-R10) is really about non-determinism — a random +value a retry regenerates. A *chosen* name does not have that defect: it is +deterministic and idempotent under retry. So a Plan being named is not the sin +minting was. The sin the implementation committed is a different one: identity in +the *path* rather than in the *content*, which no principle here permits. + +That narrows it to two honest options — a name declared *in the content*, or the +content itself — and a required human summary decides between them. A Plan +already carries a required `goal`, which is human-readable and surfaced +everywhere a Plan is listed or referenced. Human intuition is therefore already +covered without the identity carrying it. Once identity does not have to be +readable, the derived option dominates: making it a declared name would overload +one value with two jobs and reintroduce an assertion that can be typed wrong, +for a readability that `goal` already provides. + +So identity is the hash of the **origin** — the single predecessor-less version. +It is fully derived (CMP-R10), encodes no location (CMP.DM-R08), cannot collide, +and makes "the same Plan" a content fact: two versions belong to the same Plan +iff they descend from the same origin. There is a clean invariant in it — the +origin version's own identity *is* the PlanRef, since both are the hash of the +same bytes. + +The one objection DQ04 itself raised — a hash is unavailable before the first +version exists, which seems to collide with CMP-R11 (starting must be trivial) — +does not hold. Authoring a Plan references nothing by PlanRef: a first version +declares steps and a goal, imports only `compass`, and names no plan identity. +The ref comes into being when the origin is committed, which is exactly when a +Plan first exists. Starting stays a single command; the author never types or +needs a ref. + +## Options + +| Option | Tradeoffs | +| --- | --- | +| Origin content hash | Fully derived, collision-free, location-independent, "same Plan" is a content fact; opaque, and unavailable until the origin is committed | +| Name declared in `plan()` | Readable and idempotent; asserts an identity that `goal` already makes readable, and can be typed wrong | +| Catalog path segment | Simplest and matches a naive implementation; makes identity a location, which CMP.DM-R08 forbids, so a moved file changes identity | + +## Decision + +A Plan's identity, its PlanRef, is the content hash of its origin version — the +version with no predecessor. It is derived, never declared and never minted. + +Two versions are the same Plan when they share an origin. The catalog files a +Plan under its PlanRef, and a version whose derived Plan does not match where it +is filed is rejected rather than reinterpreted, on the same terms as a version +whose content does not match its own name. + +`goal` is required on every version and is the human handle: what `compass` +shows in listings and references in place of the hash. Identity is machine-facing +and derived; readability is human-facing and lives in `goal`. Neither carries the +other's job. + +## Consequences + +- The PlanRef is not known until the origin is committed. This does not affect + starting or authoring, which reference no PlanRef; it affects only how a Plan + is addressed afterwards, where `goal` is the readable handle and the hash is + the exact one. +- The origin version's identity and the PlanRef are the same hash. A Plan is, + precisely, its first stated intent. +- Cross-plan references resolve to a PlanRef and so are content-addressed; their + import paths are opaque, which is acceptable because they are machine-written. +- Renaming is not an operation. A Plan cannot be renamed because it was never + named; its `goal` can be revised like any other intent, and its identity is + unaffected because identity is the origin, not the goal. +- Moving or misfiling a version cannot change its Plan: the Plan is derived from + the origin it descends from, and a mismatch with where it is filed is rejected. diff --git a/context/01-data-model/requirements.md b/context/01-data-model/requirements.md index e37a7d1..4d3128b 100644 --- a/context/01-data-model/requirements.md +++ b/context/01-data-model/requirements.md @@ -125,6 +125,21 @@ incidental, because without it a committed Step can be re-identified while every hash in the lineage stays constant. _refines: CMP-R02, CMP-R07._ +- **CMP.DM-R11 A Plan's identity is its origin.** A Plan is identified by the + content hash of its origin — the one version with no predecessor. It is + derived, never declared and never minted, and encodes no location. Two + versions are the same Plan when they share an origin. A version whose derived + Plan disagrees with where it is filed is rejected, not reinterpreted. + _refines: CMP-R10, CMP-R02._ + +- **CMP.DM-R12 A goal is required and is the human handle.** Every version + states a goal, and the goal is what identifies a Plan to a person — surfaced + wherever a Plan is listed or referenced, in place of its hash. Identity is + derived and machine-facing; readability is the goal's job, so neither carries + the other's. A Plan is never renamed, because it was never named: its goal is + revised like any other intent, and its identity, being the origin, is + unaffected. _refines: CMP-R03, CMP-R11._ + ### Progress and acceptance - **CMP.DM-R11 Progress is append-only.** Progress records never alter intent diff --git a/context/01-data-model/spec.md b/context/01-data-model/spec.md index 3ab898d..da73dcb 100644 --- a/context/01-data-model/spec.md +++ b/context/01-data-model/spec.md @@ -6,7 +6,10 @@ Realizes [requirements.md](./requirements.md). Storage is specified in ## Plan A Plan is a lineage of Versions plus the Progress recorded against them. It is -named by a `PlanRef`; what determines that reference is unresolved (DQ04). +named by a `PlanRef` — the content hash of its origin, the one version with no +predecessor (decision 0017). Identity is derived from the origin, so two +versions are the same Plan iff they share one; the origin's own identity is the +PlanRef. The human handle for a Plan is its required `goal`, not the PlanRef. ## Version diff --git a/context/02-artifacts/spec.md b/context/02-artifacts/spec.md index 4476c36..186a72e 100644 --- a/context/02-artifacts/spec.md +++ b/context/02-artifacts/spec.md @@ -6,10 +6,17 @@ Realizes [requirements.md](./requirements.md). The logical model it stores is in ## Layout ```text -catalog/plans//versions/-.ts immutable, mode 0444 -catalog/plans//events/-. append-only +catalog/plans//versions/-.ts immutable, mode 0444 +catalog/plans//events/-. append-only ``` +`` is the Plan's identity: the content hash of its origin version +(decision 0017). A Plan is filed under it; it is derived, not chosen, so no two +Plans collide and no Plan is named. A version whose origin resolves to a +different PlanRef than the directory it sits in is rejected, on the same terms as +a version whose content does not match its own name — a misfiled version is never +reinterpreted into the Plan it was filed under. + `seq` is a reading aid, not a key. Divergent versions may share one, and after a reconciliation of unequal lineages it follows the longest predecessor. Nothing resolves on `seq`; the hash is the identity. diff --git a/context/04-cli/spec.md b/context/04-cli/spec.md index 0580297..6c81251 100644 --- a/context/04-cli/spec.md +++ b/context/04-cli/spec.md @@ -40,6 +40,12 @@ Authoring *new* content that revises nothing is a different case and is refused with a different message. One says "this is already committed"; the other says "this changes nothing." Rendering them alike would hide which happened. +Committing an origin — a module with no predecessor — brings a Plan into being, +and its identity is derived then: the PlanRef is the hash of that origin +(decision 0017). The operator names nothing. Afterwards a Plan is addressed by +its `goal` where a person is reading and by its PlanRef where exactness is +needed; a command that reports a Plan shows the `goal`, not the hash. + Verification and repair are separate commands. Verification is safe to run anywhere at any time; repair authors permanent content that replication makes irreversible. Collapsing them into one command with a flag would make the diff --git a/context/06-api/spec.md b/context/06-api/spec.md index fac5822..497b553 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -50,6 +50,11 @@ export default plan({ A Step declared inline in the `steps` array without a binding has no identity and is refused: identity must be a name a reader and a successor can refer to. +`plan({...})` declares no identity of its own. A Plan's identity is the content +hash of this origin version, derived at commit (decision 0017) — there is no `id` +to write and none to get wrong. `goal` is required and is the Plan's human +handle, shown wherever a Plan is listed or referenced in place of the hash. + ## A revision A revision imports its predecessor and is an operation on it. It carries every diff --git a/context/ontology.md b/context/ontology.md index 1dfbd7d..d5aff91 100644 --- a/context/ontology.md +++ b/context/ontology.md @@ -110,9 +110,13 @@ is retired, and a Step declared without a name has no identity and is refused. _Avoid_: minted id, content hash, array index, title slug, opaque token **PlanRef**: -A stable reference to a Plan. It encodes no filesystem, database, transport, or -host location. What determines it is unresolved; see DQ04. -_Avoid_: plan path, catalog path, file name +A Plan's identity: the content hash of its origin — the single predecessor-less +version. It is derived, never declared and never minted, and encodes no +filesystem, database, transport, or host location. Two versions are the same +Plan when they share an origin; the origin version's own identity and the +PlanRef are the same hash. It is machine-facing; the human handle for a Plan is +its `goal`. +_Avoid_: plan path, catalog path, file name, plan name, declared id **Catalog**: The on-disk tree of Plans. Discovery is content-based: the tree is walked and diff --git a/context/open-questions.md b/context/open-questions.md index 5df6ecb..942f0e4 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -36,18 +36,13 @@ cannot report why it failed is unusable regardless of expressive power. This is the largest open question in the model, and the contract is incomplete without it. -**DQ04 — What determines a Plan's identity?** -Decision 0012 settles Step identity — the name it is declared under, qualified -by its Plan — and removes the minting mechanism that supplied both Step and Plan -references. It does not say what supplies a Plan reference instead, and -"qualified by its Plan" presupposes a Plan handle that nothing defines. - -The candidates differ in what they cost. A declared name has the same virtues it -has for a Step and needs somewhere to be declared that is not itself a Plan. A -content hash of the first version is derived and stable but unreadable and -unavailable before the first version exists, which collides with CMP-R11. A path -segment makes identity a location, which CMP.FS-R05 and the ontology both -refuse. +**DQ04 — Resolved.** See +[decision 0017](./.decisions/0017-a-plans-identity-is-its-origin.md). A Plan's +identity is the content hash of its origin (predecessor-less) version — derived, +never declared or minted, encoding no location. Two versions are the same Plan +when they share an origin, and the origin's own identity is the PlanRef. Human +readability is carried by the required `goal`, not by the identity, so the +identity need not be readable and is fully derived. **DQ05 — What is a Plan scoped to?** The catalog is a single tree replicating across machines. Unresolved: whether a From 9897e998af256a55ee2567962510ce27a250a7df Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:07:33 +0200 Subject: [PATCH 2/8] feat(compass): a Plan's identity is its origin (decision 0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the operator-chosen catalog directory name with a derived PlanRef: the content hash of a Plan's origin — the single predecessor-less version. Identity is derived, never chosen, never minted, and encodes no location. - catalog: derive_planref walks predecessor imports to the origin and hashes it; an origin is its own PlanRef (its version-id and the PlanRef are the same hash). load_plan rejects a version whose derived origin != the dir it is filed under, on the same terms as a content-hash-vs-filename mismatch — never reinterpreted. - cli/cmd: start names no plan and scaffolds into a drafts/ staging area with a blank (required) goal; commit derives the PlanRef and files under it — --plan and args removed. Reads address a Plan by PlanRef (the dir), a unique hash prefix, or, as a nicety, an unambiguous goal. - model: a non-empty goal is required on every version (CMP.DM-R12), checked on the evaluated value so revisions inheriting their goal pass. - display: status and history lead with the goal (the human handle), not the hash. - examples: re-file the three plans under their origin hashes (bytes unchanged); READMEs refer to each plan by its goal and note the PlanRef is the origin hash. - tests: cover origin-files-under-own-hash, revision-shares-PlanRef, misfiled rejection, empty-goal refusal, and goal display; rewrite the e2e + cross-plan suites for the derived-identity, no-name-arg flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 +- examples/editorial-review/README.md | 10 +- .../versions/001-cfe4f8d721d2.ts | 0 .../versions/002-043517240262.ts | 0 examples/hypothesis-dies/README.md | 11 +- .../versions/001-634e2a7c458b.ts | 0 .../versions/002-68fdda593f4d.ts | 0 .../versions/003-55ca61867911.ts | 0 examples/two-machines/README.md | 14 +- .../versions/001-8e528ff9bc56.ts | 0 .../versions/002-2c922cb978de.ts | 0 .../versions/002-e5d27ee538bf.ts | 0 .../versions/003-037d5ddb9db7.ts | 0 src/catalog.rs | 180 +++++++++++++- src/cli.rs | 80 +++--- src/cmd.rs | 153 ++++++++---- src/model.rs | 11 + tests/acceptance.rs | 234 ++++++++++++++---- 18 files changed, 561 insertions(+), 142 deletions(-) rename examples/editorial-review/catalog/plans/{pl_agent_memory_piece => cfe4f8d721d2}/versions/001-cfe4f8d721d2.ts (100%) rename examples/editorial-review/catalog/plans/{pl_agent_memory_piece => cfe4f8d721d2}/versions/002-043517240262.ts (100%) rename examples/hypothesis-dies/catalog/plans/{pl_ci_speed => 634e2a7c458b}/versions/001-634e2a7c458b.ts (100%) rename examples/hypothesis-dies/catalog/plans/{pl_ci_speed => 634e2a7c458b}/versions/002-68fdda593f4d.ts (100%) rename examples/hypothesis-dies/catalog/plans/{pl_ci_speed => 634e2a7c458b}/versions/003-55ca61867911.ts (100%) rename examples/two-machines/catalog/plans/{pl_nested_groups => 8e528ff9bc56}/versions/001-8e528ff9bc56.ts (100%) rename examples/two-machines/catalog/plans/{pl_nested_groups => 8e528ff9bc56}/versions/002-2c922cb978de.ts (100%) rename examples/two-machines/catalog/plans/{pl_nested_groups => 8e528ff9bc56}/versions/002-e5d27ee538bf.ts (100%) rename examples/two-machines/catalog/plans/{pl_nested_groups => 8e528ff9bc56}/versions/003-037d5ddb9db7.ts (100%) diff --git a/README.md b/README.md index ff2941c..28a0387 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,14 @@ you resolve by hand. That trade is recorded, not hidden: ## How it is stored ``` -catalog/plans//versions/-.ts immutable, mode 0444 -catalog/plans//events/-... append-only +catalog/plans//versions/-.ts immutable, mode 0444 +catalog/plans//events/-... append-only ``` -A committed version *is* the module you wrote, stored unchanged and named by the -hash of its bytes. There is no separate rendered form, so nothing can drift from +A `` is the Plan's identity: the content hash of its origin — the first +version — so a Plan is named by nothing and filed under a value it derives +(decision 0017). A committed version *is* the module you wrote, stored unchanged +and named by the hash of its bytes. There is no separate rendered form, so nothing can drift from what you authored, and altering a committed version changes its hash — which is how tampering is caught. A revision imports its predecessor by that hashed name, so the lineage is a real module graph. diff --git a/examples/editorial-review/README.md b/examples/editorial-review/README.md index ea2615e..ad38e74 100644 --- a/examples/editorial-review/README.md +++ b/examples/editorial-review/README.md @@ -51,5 +51,11 @@ and says so. ## Files -- [`001-cfe4f8d721d2.ts`](./catalog/plans/pl_agent_memory_piece/versions/001-cfe4f8d721d2.ts) — the plan -- [`002-043517240262.ts`](./catalog/plans/pl_agent_memory_piece/versions/002-043517240262.ts) — narrowed to two tools +This plan has no name. Its identity — its PlanRef — is the content hash of its +origin, the first version (decision 0017), so the plan directory is +`cfe4f8d721d2`, the same hash the `001` file carries. To a person it is its goal, +"Publish a defensible comparison of agent-memory tools"; the hash is only for +exactness. + +- [`001-cfe4f8d721d2.ts`](./catalog/plans/cfe4f8d721d2/versions/001-cfe4f8d721d2.ts) — the plan +- [`002-043517240262.ts`](./catalog/plans/cfe4f8d721d2/versions/002-043517240262.ts) — narrowed to two tools diff --git a/examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/001-cfe4f8d721d2.ts b/examples/editorial-review/catalog/plans/cfe4f8d721d2/versions/001-cfe4f8d721d2.ts similarity index 100% rename from examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/001-cfe4f8d721d2.ts rename to examples/editorial-review/catalog/plans/cfe4f8d721d2/versions/001-cfe4f8d721d2.ts diff --git a/examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/002-043517240262.ts b/examples/editorial-review/catalog/plans/cfe4f8d721d2/versions/002-043517240262.ts similarity index 100% rename from examples/editorial-review/catalog/plans/pl_agent_memory_piece/versions/002-043517240262.ts rename to examples/editorial-review/catalog/plans/cfe4f8d721d2/versions/002-043517240262.ts diff --git a/examples/hypothesis-dies/README.md b/examples/hypothesis-dies/README.md index ab6e0cd..2f84a28 100644 --- a/examples/hypothesis-dies/README.md +++ b/examples/hypothesis-dies/README.md @@ -49,6 +49,11 @@ wrong. ## Files -- [`001-634e2a7c458b.ts`](./catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts) — the hypothesis -- [`002-68fdda593f4d.ts`](./catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts) — it dies -- [`003-55ca61867911.ts`](./catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts) — the cold-start follow-on +This plan has no name. Its identity — its PlanRef — is the content hash of its +origin, the first version (decision 0017), so the plan directory is +`634e2a7c458b`, the same hash the `001` file carries. To a person it is its goal, +"CI builds finish under 10 minutes"; the hash is only for exactness. + +- [`001-634e2a7c458b.ts`](./catalog/plans/634e2a7c458b/versions/001-634e2a7c458b.ts) — the hypothesis +- [`002-68fdda593f4d.ts`](./catalog/plans/634e2a7c458b/versions/002-68fdda593f4d.ts) — it dies +- [`003-55ca61867911.ts`](./catalog/plans/634e2a7c458b/versions/003-55ca61867911.ts) — the cold-start follow-on diff --git a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts b/examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/001-634e2a7c458b.ts similarity index 100% rename from examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/001-634e2a7c458b.ts rename to examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/001-634e2a7c458b.ts diff --git a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts b/examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/002-68fdda593f4d.ts similarity index 100% rename from examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/002-68fdda593f4d.ts rename to examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/002-68fdda593f4d.ts diff --git a/examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts b/examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/003-55ca61867911.ts similarity index 100% rename from examples/hypothesis-dies/catalog/plans/pl_ci_speed/versions/003-55ca61867911.ts rename to examples/hypothesis-dies/catalog/plans/634e2a7c458b/versions/003-55ca61867911.ts diff --git a/examples/two-machines/README.md b/examples/two-machines/README.md index 7c11c65..acc2844 100644 --- a/examples/two-machines/README.md +++ b/examples/two-machines/README.md @@ -56,7 +56,13 @@ added. ## Files -- [`001-8e528ff9bc56.ts`](./catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts) — the shared base -- [`002-2c922cb978de.ts`](./catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts) — machine A: fuzz -- [`002-e5d27ee538bf.ts`](./catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts) — machine B: guard -- [`003-037d5ddb9db7.ts`](./catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts) — reconciliation +This plan has no name. Its identity — its PlanRef — is the content hash of its +origin, the shared base `001` (decision 0017), so the plan directory is +`8e528ff9bc56`, the same hash the `001` file carries. Both `002` sides descend +from that origin, so they are the same Plan. To a person it is its goal, "Nested +groups parse correctly"; the hash is only for exactness. + +- [`001-8e528ff9bc56.ts`](./catalog/plans/8e528ff9bc56/versions/001-8e528ff9bc56.ts) — the shared base +- [`002-2c922cb978de.ts`](./catalog/plans/8e528ff9bc56/versions/002-2c922cb978de.ts) — machine A: fuzz +- [`002-e5d27ee538bf.ts`](./catalog/plans/8e528ff9bc56/versions/002-e5d27ee538bf.ts) — machine B: guard +- [`003-037d5ddb9db7.ts`](./catalog/plans/8e528ff9bc56/versions/003-037d5ddb9db7.ts) — reconciliation diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts b/examples/two-machines/catalog/plans/8e528ff9bc56/versions/001-8e528ff9bc56.ts similarity index 100% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/001-8e528ff9bc56.ts rename to examples/two-machines/catalog/plans/8e528ff9bc56/versions/001-8e528ff9bc56.ts diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts b/examples/two-machines/catalog/plans/8e528ff9bc56/versions/002-2c922cb978de.ts similarity index 100% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/002-2c922cb978de.ts rename to examples/two-machines/catalog/plans/8e528ff9bc56/versions/002-2c922cb978de.ts diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts b/examples/two-machines/catalog/plans/8e528ff9bc56/versions/002-e5d27ee538bf.ts similarity index 100% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/002-e5d27ee538bf.ts rename to examples/two-machines/catalog/plans/8e528ff9bc56/versions/002-e5d27ee538bf.ts diff --git a/examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts b/examples/two-machines/catalog/plans/8e528ff9bc56/versions/003-037d5ddb9db7.ts similarity index 100% rename from examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts rename to examples/two-machines/catalog/plans/8e528ff9bc56/versions/003-037d5ddb9db7.ts diff --git a/src/catalog.rs b/src/catalog.rs index 70cc7cc..97126c1 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -130,6 +130,12 @@ pub fn versions_dir(root: &Path, plan: &str) -> PathBuf { pub fn events_dir(root: &Path, plan: &str) -> PathBuf { plan_dir(root, plan).join("events") } +/// Where `start` scaffolds a draft before its identity is known. It is a sibling +/// of `plans/`, never under it, so `list_plans` never mistakes a draft for a Plan +/// — a draft has no PlanRef until it is committed (decision 0017). +pub fn drafts_dir(root: &Path) -> PathBuf { + root.join("drafts") +} /// Create the catalog skeleton. Idempotent. pub fn init(root: &Path) -> Result<(), String> { @@ -207,6 +213,8 @@ pub fn load_plan(root: &Path, plan: &str) -> Result { }); } + reject_misfiled(&mut store, plan); + let edir = events_dir(root, plan); if edir.is_dir() { for path in sorted_files(&edir)? { @@ -320,6 +328,133 @@ fn predecessor_prefixes( Ok(out) } +/// Reject versions misfiled under the wrong Plan (decision 0017, CMP.DM-R11). +/// +/// A Plan's identity is the content hash of its origin. A version whose derived +/// origin resolves to a PlanRef different from the directory it sits in is +/// rejected — never reinterpreted into the Plan it was filed under — on the same +/// terms as a version whose content hash disagrees with its own filename. +/// +/// The origin is derived by walking resolved predecessor pointers within the +/// store (no evaluation, no extra IO) to the predecessor-less ancestor. When the +/// walk reaches an ancestor that is absent locally the version is an orphan, not +/// a misfiling: its Plan cannot yet be confirmed, so it is left alone. +fn reject_misfiled(store: &mut PlanStore, plan: &str) { + use std::collections::{HashMap, HashSet}; + let parents_of: HashMap> = store + .versions + .iter() + .map(|a| (a.hash.clone(), a.parents.clone())) + .collect(); + let present: HashSet = store.versions.iter().map(|a| a.hash.clone()).collect(); + + // The origin PlanRef of a version, or None when an ancestor is absent (an + // orphan, whose Plan cannot be confirmed) or the lineage forms a cycle. + let origin_prefix = |start: &str| -> Option { + let mut cur = start.to_string(); + let mut seen: HashSet = HashSet::new(); + loop { + if !seen.insert(cur.clone()) { + return None; + } + let parents = parents_of.get(&cur)?; + if parents.is_empty() { + return Some(cur[..crate::model::HASH_PREFIX_LEN.min(cur.len())].to_string()); + } + match parents.iter().find(|p| present.contains(*p)) { + Some(p) => cur = p.clone(), + None => return None, + } + } + }; + + let mut kept = Vec::new(); + for a in std::mem::take(&mut store.versions) { + match origin_prefix(&a.hash) { + Some(pref) if pref != plan => store.rejected.push(Rejected { + reason: format!( + "misfiled: its origin resolves to plan {pref}, but it is filed under {plan} \ + (decision 0017) — a misfiled version is never reinterpreted into the plan it \ + was filed under" + ), + path: a.path, + }), + _ => kept.push(a), + } + } + store.versions = kept; +} + +/// Derive a version's PlanRef from its authored bytes (decision 0017). +/// +/// A Plan's identity is the content hash of its origin — the single +/// predecessor-less version. An origin (a module that imports no predecessor) is +/// its own PlanRef: the hash of its bytes, the same hash its version filename +/// carries. A revision inherits its Plan from its predecessor: its origin is +/// found by walking the predecessor imports back to the predecessor-less version, +/// and hashing that. The operator names nothing; identity is derived, and the +/// prefix width matches the version filenames' for consistency. +pub fn derive_planref(path: &Path, source: &[u8]) -> Result { + let src = std::str::from_utf8(source) + .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; + let origin_bytes = match sibling_predecessor_paths(path, src)?.into_iter().next() { + None => source.to_vec(), + Some(pred) => walk_to_origin(&pred)?, + }; + Ok(crate::sha256::sha256_hex(&origin_bytes)[..crate::model::HASH_PREFIX_LEN].to_string()) +} + +/// The resolved paths of the *predecessor* version files a module imports — the +/// same-plan siblings, sitting in the importer's own directory. A cross-plan +/// reference resolves elsewhere and is not a predecessor, so it is excluded, as +/// is the `compass` prelude (which is not a version reference). +fn sibling_predecessor_paths(path: &Path, source: &str) -> Result, String> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let specs = crate::eval::import_specifiers(source, path) + .map_err(|e| format!("cannot read imports: {}", e.message()))?; + let mut out = Vec::new(); + for spec in specs { + let file = spec.rsplit('/').next().unwrap_or(&spec); + if crate::model::parse_filename(file).is_none() { + continue; + } + if let Some(target) = crate::eval::normalize_lexical(dir, &spec) { + if target.parent() == Some(dir) { + out.push(target); + } + } + } + Ok(out) +} + +/// Walk a predecessor chain to its origin and return the origin's raw bytes. +/// Any predecessor of a version shares that version's origin, so following one +/// predecessor at each step suffices. +fn walk_to_origin(pred: &Path) -> Result, String> { + let mut current = pred.to_path_buf(); + let mut seen = std::collections::HashSet::new(); + loop { + if !seen.insert(current.clone()) { + return Err( + "predecessor lineage forms a cycle; a plan's identity cannot be derived".into(), + ); + } + let bytes = fs::read(¤t).map_err(|_| { + format!( + "predecessor {} has not arrived: a plan's identity is its origin, which cannot be \ + derived until the origin is present (decision 0017)", + current.display() + ) + })?; + let src = std::str::from_utf8(&bytes) + .map_err(|e| format!("{}: not valid UTF-8: {e}", current.display()))?; + match sibling_predecessor_paths(¤t, src)?.into_iter().next() { + None => return Ok(bytes), + Some(next) => current = next, + } + } +} + /// Write a Plan Version from its authored source bytes. /// /// Returns its path and whether it was newly created. The name is the content @@ -427,16 +562,23 @@ export const a = step({ work: "do a", accept: evidence.test({ status: "pass" }) export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) "#; + /// The PlanRef a module files under: the hash-prefix of its own bytes, for an + /// origin (decision 0017). + fn planref_of(source: &str) -> String { + crate::sha256::sha256_hex(source.as_bytes())[..crate::model::HASH_PREFIX_LEN].to_string() + } + #[test] fn admits_a_version_by_source_byte_hash() { let s = Scratch::new("admit"); + let plan = planref_of(ROOT_MODULE); let (path, hash, created) = - write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); + write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(created); assert!(path.exists()); assert_eq!(hash, crate::sha256::sha256_hex(ROOT_MODULE.as_bytes())); - let store = load_plan(&s.root, "pl_x").unwrap(); + let store = load_plan(&s.root, &plan).unwrap(); assert_eq!(store.versions.len(), 1, "rejected: {:?}", store.rejected); assert_eq!(store.versions[0].hash, hash); assert!(store.versions[0].parents.is_empty()); @@ -445,8 +587,9 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) #[test] fn identical_content_is_a_no_op() { let s = Scratch::new("noop"); - let (_, _, first) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); - let (_, _, second) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); + let plan = planref_of(ROOT_MODULE); + let (_, _, first) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); + let (_, _, second) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(first); assert!(!second); } @@ -454,19 +597,40 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) #[test] fn tampered_bytes_are_rejected() { let s = Scratch::new("tamper"); - let (path, _, _) = write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); + let plan = planref_of(ROOT_MODULE); + let (path, _, _) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); make_writable_recursive(&s.root).unwrap(); fs::write(&path, b"import x from 'y'\n").unwrap(); - let store = load_plan(&s.root, "pl_x").unwrap(); + let store = load_plan(&s.root, &plan).unwrap(); assert!(store.versions.is_empty()); assert!(store.rejected[0].reason.contains("content hash mismatch")); } + /// An origin filed under a directory that is not its own hash is rejected as + /// misfiled, distinctly from tampering: the filename still matches the + /// content (decision 0017). + #[test] + fn a_misfiled_version_is_rejected() { + let s = Scratch::new("misfiled"); + let wrong = "999999999999"; + assert_ne!(wrong, planref_of(ROOT_MODULE)); + let (path, _, _) = write_version(&s.root, wrong, 1, ROOT_MODULE.as_bytes()).unwrap(); + assert!(path.exists()); + let store = load_plan(&s.root, wrong).unwrap(); + assert!( + store.versions.is_empty(), + "a misfiled origin is not admitted" + ); + assert_eq!(store.rejected.len(), 1); + assert!(store.rejected[0].reason.contains("misfiled")); + } + #[test] fn a_version_evaluates_to_its_intent() { let s = Scratch::new("eval"); - write_version(&s.root, "pl_x", 1, ROOT_MODULE.as_bytes()).unwrap(); - let store = load_plan(&s.root, "pl_x").unwrap(); + let plan = planref_of(ROOT_MODULE); + write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); + let store = load_plan(&s.root, &plan).unwrap(); let v = evaluate(&store.versions[0]).unwrap(); assert_eq!(v.goal, "Ship"); assert_eq!(v.steps.len(), 1); diff --git a/src/cli.rs b/src/cli.rs index 0dba7d0..e48d80e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,15 +15,15 @@ pub const EXIT_FAILURE: i32 = 1; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { - /// Scaffold a runnable starter module (CMP-R11): one command, no docs. + /// Scaffold a runnable starter module (CMP-R11): one command, no docs, no + /// identifier — a Plan's identity is derived from its origin at commit + /// (decision 0017). Start { - plan: String, goal: Option, }, - /// Evaluate a module and store it by the hash of its source bytes. + /// Evaluate a module and store it under its derived PlanRef (decision 0017). Commit { path: PathBuf, - plan: Option, }, /// The lineage and current intent. Show { @@ -175,36 +175,33 @@ fn one_positional(rest: Vec, cmd: &str, spec: &str) -> Result) -> Result { - let mut plan = None; let mut goal = None; let mut it = rest.into_iter(); while let Some(a) = it.next() { match a.as_str() { "--goal" => goal = Some(it.next().ok_or("`--goal` needs text")?), - other if other.starts_with('-') => { - return Err(format!("`start`: unexpected argument `{other}`")) - } other => { - if plan.is_some() { - return Err("`start` takes one ".into()); - } - plan = Some(other.to_string()); + return Err(format!( + "`start` takes no plan name — identity is derived at commit (decision 0017); \ + unexpected argument `{other}`\n usage: compass start [--goal ]" + )) } } } - Ok(Command::Start { - plan: plan.ok_or("usage: compass start [--goal ]")?, - goal, - }) + Ok(Command::Start { goal }) } fn parse_commit(rest: Vec) -> Result { let mut path = None; - let mut plan = None; - let mut it = rest.into_iter(); - while let Some(a) = it.next() { + for a in rest { match a.as_str() { - "--plan" => plan = Some(it.next().ok_or("`--plan` needs a plan ref")?), + "--plan" => { + return Err( + "`commit` no longer takes `--plan`: a Plan's identity is derived from \ + its origin at commit (decision 0017)" + .into(), + ) + } other if other.starts_with('-') => { return Err(format!("`commit`: unexpected argument `{other}`")) } @@ -217,8 +214,7 @@ fn parse_commit(rest: Vec) -> Result { } } Ok(Command::Commit { - path: path.ok_or("usage: compass commit [--plan ]")?, - plan, + path: path.ok_or("usage: compass commit ")?, }) } @@ -323,23 +319,26 @@ fn take_plan(rest: &mut Vec, cmd: &str) -> Result { pub fn help(topic: Option<&str>) -> String { match topic { Some("commit") => "\ -compass commit [--plan ] +compass commit - Evaluate a module and store it by the hash of its source bytes. - A version is the authored module, stored unchanged (decision 0014). + Evaluate a module and store it under its derived PlanRef (decision 0017). + A version is the authored module, stored unchanged (decision 0014); a Plan's + identity is the content hash of its origin, derived here — you name nothing. - Committing content already present is a no-op success. - New content that revises nothing is refused, with a distinct message. - A module uses plan() for a first version, prior.revise({...}) for a revision, or reconcile({revises:[...]}) for a reconciliation. Predecessors - are the version files it imports. + are the version files it imports; the Plan is derived from the origin they + descend from. " .to_string(), Some("start") => "\ -compass start [--goal ] +compass start [--goal ] - Scaffold a runnable starter module for a new plan, then print where it is. - Edit it and `compass commit` it. Nothing to import, configure, or look up. + Scaffold a runnable starter module, then print where it is. Edit it and + `compass commit` it. Nothing to import, configure, name, or look up — a Plan's + identity is derived from its origin when you commit (decision 0017). " .to_string(), Some("evidence") => "\ @@ -361,8 +360,8 @@ compass — durable planning intent for coding agents usage: compass [options] - start [--goal ] scaffold a starter module - commit [--plan

] evaluate a module and store it + start [--goal ] scaffold a starter module + commit evaluate a module and store it show lineage and current intent history the Rationale chain ready what work is available now @@ -373,6 +372,9 @@ usage: compass [options] evidence k=v record evidence acceptance evaluates version build identity +A is addressed by its PlanRef (the origin's hash) or, when unambiguous, +by its goal. A Plan is never named: its identity is derived (decision 0017). + global options: --json machine-readable output, same fields as the human form --catalog override the catalog root @@ -402,21 +404,27 @@ mod tests { #[test] fn start_and_commit() { + // start names no plan (identity is derived at commit, decision 0017). assert_eq!( - p(&["start", "pl_x", "--goal", "Ship"]).unwrap().command, + p(&["start", "--goal", "Ship"]).unwrap().command, Command::Start { - plan: "pl_x".into(), goal: Some("Ship".into()) } ); assert_eq!( - p(&["commit", "a.ts", "--plan", "pl_x"]).unwrap().command, + p(&["start"]).unwrap().command, + Command::Start { goal: None } + ); + // a stray positional is refused — there is no plan name to give. + assert!(p(&["start", "pl_x"]).is_err()); + assert_eq!( + p(&["commit", "a.ts"]).unwrap().command, Command::Commit { path: PathBuf::from("a.ts"), - plan: Some("pl_x".into()) } ); - assert!(p(&["start"]).is_err()); + // `--plan` is gone. + assert!(p(&["commit", "a.ts", "--plan", "pl_x"]).is_err()); assert!(p(&["commit"]).is_err()); } diff --git a/src/cmd.rs b/src/cmd.rs index dcd4feb..1264f08 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -49,8 +49,8 @@ pub fn execute(inv: &Invocation) -> Result { Json::obj(vec![("command", Json::str("help"))]), )), Command::Version => Ok(cmd_version()), - Command::Start { plan, goal } => cmd_start(&root, plan, goal.as_deref(), &author), - Command::Commit { path, plan } => cmd_commit(&root, path, plan.as_deref()), + Command::Start { goal } => cmd_start(&root, goal.as_deref(), &author), + Command::Commit { path } => cmd_commit(&root, path), Command::Show { plan } => cmd_show(&root, plan), Command::History { plan } => cmd_history(&root, plan), Command::Ready { plan } => cmd_ready(&root, plan), @@ -99,13 +99,54 @@ fn convergence_json(c: &Convergence) -> (&'static str, Json) { fn load(root: &Path, plan: &str) -> Result { if !catalog::exists(root) { return Err(format!( - "no catalog at {}\n run `compass start ` to begin", + "no catalog at {}\n run `compass start` to begin", root.display() )); } catalog::load_plan(root, plan) } +/// Resolve a Plan address to its PlanRef directory (decision 0017). A Plan is +/// addressed by its PlanRef — the origin's hash, which is the directory name — so +/// that always works. As a convenience it also resolves an unambiguous hash +/// prefix, and, failing that, an exact `goal` match when it is unique. Exactness +/// is the hash; goal-resolution is a nicety and never guesses. +fn resolve_plan(root: &Path, addr: &str) -> String { + if !catalog::exists(root) { + return addr.to_string(); + } + // Exact PlanRef: a directory by that name. + if catalog::plan_dir(root, addr).is_dir() { + return addr.to_string(); + } + let plans = catalog::list_plans(root).unwrap_or_default(); + // A unique hash-prefix of a PlanRef. + let prefix_hits: Vec<&String> = plans.iter().filter(|p| p.starts_with(addr)).collect(); + if prefix_hits.len() == 1 { + return prefix_hits[0].clone(); + } + // A unique exact goal match, evaluated at each Plan's head. + let mut goal_hits: Vec = Vec::new(); + for p in &plans { + let Ok(store) = catalog::load_plan(root, p) else { + continue; + }; + let an = chain::analyze(&store); + if an + .head + .iter() + .filter_map(|h| evaluate(h).ok()) + .any(|v| v.goal == addr) + { + goal_hits.push(p.clone()); + } + } + if goal_hits.len() == 1 { + return goal_hits.remove(0); + } + addr.to_string() +} + /// Evaluate one admitted version to its intent, mapping the read failure modes. fn evaluate(a: &Admitted) -> Result { catalog::evaluate(a) @@ -179,37 +220,50 @@ fn cmd_version() -> Output { // start // --------------------------------------------------------------------------- -fn cmd_start(root: &Path, plan: &str, goal: Option<&str>, author: &str) -> Result { +fn cmd_start(root: &Path, goal: Option<&str>, author: &str) -> Result { catalog::init(root)?; - let dir = catalog::versions_dir(root, plan); + // A draft has no PlanRef yet — identity is derived from the origin at commit + // (decision 0017) — so it is scaffolded into a staging area, not a plan dir. + let dir = catalog::drafts_dir(root); std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - let draft = dir.join("_new.ts"); - if draft.exists() { - return Err(format!( - "a draft already exists at {}\n edit it and `compass commit {}`", - draft.display(), - draft.display() - )); - } - let goal = goal.unwrap_or("State the goal here"); + let draft = next_draft_path(&dir); + + // A blank goal by default: the goal is required (CMP.DM-R12), so committing + // the scaffold unedited is refused — the operator must state the goal. + let goal = goal.unwrap_or(""); let scaffold = starter_module(goal, author); std::fs::write(&draft, &scaffold) .map_err(|e| format!("cannot write {}: {e}", draft.display()))?; let text = format!( - "scaffolded a starter plan for {}\n edit {}\n commit compass commit {}\n", - style::bold(plan), + "scaffolded a starter plan\n edit {}\n commit compass commit {}\n {}\n", + draft.display(), draft.display(), - draft.display() + style::dim("its identity is derived from the origin when you commit — you name nothing"), ); let json = Json::obj(vec![ ("command", Json::str("start")), - ("plan", Json::str(plan)), ("draft", Json::str(draft.to_string_lossy())), ]); Ok(Output::ok(text, json)) } +/// A non-colliding draft path in the staging area, so `start` always succeeds +/// and never asks for an identifier: `plan.ts`, then `plan-2.ts`, and so on. +fn next_draft_path(dir: &Path) -> PathBuf { + let first = dir.join("plan.ts"); + if !first.exists() { + return first; + } + for n in 2.. { + let p = dir.join(format!("plan-{n}.ts")); + if !p.exists() { + return p; + } + } + unreachable!("an unbounded search for a free draft name cannot exhaust") +} + fn starter_module(goal: &str, author: &str) -> String { format!( r#"import {{ plan, step, evidence }} from "compass" @@ -223,7 +277,7 @@ export const first = step({{ export default plan({{ author: {author:?}, - goal: {goal:?}, + goal: {goal:?}, // required — the human handle for this plan (decision 0017) why: "Why this plan exists — the durable record of reasons.", steps: [first], }}) @@ -235,16 +289,15 @@ export default plan({{ // commit // --------------------------------------------------------------------------- -fn cmd_commit(root: &Path, path: &Path, plan_opt: Option<&str>) -> Result { +fn cmd_commit(root: &Path, path: &Path) -> Result { let source = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?; let source_str = String::from_utf8(source.clone()) .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; - let plan = match plan_opt { - Some(p) => p.to_string(), - None => infer_plan(path) - .ok_or_else(|| "cannot determine the plan; pass --plan ".to_string())?, - }; + // A Plan's identity is derived from its origin (decision 0017): the operator + // names nothing. An origin is its own PlanRef; a revision inherits its Plan + // from the predecessor it descends from. + let plan = catalog::derive_planref(path, &source)?; // Evaluate the authored module (imports resolve at its location). let map = crate::eval::eval_plan_file(path).map_err(|e| { @@ -354,10 +407,12 @@ fn cmd_commit(root: &Path, path: &Path, plan_opt: Option<&str>) -> Result/versions/`. -fn infer_plan(path: &Path) -> Option { - let versions = path.parent()?; - if versions.file_name()?.to_str()? != "versions" { - return None; - } - let plan_dir = versions.parent()?; - Some(plan_dir.file_name()?.to_str()?.to_string()) -} - // --------------------------------------------------------------------------- // show // --------------------------------------------------------------------------- fn cmd_show(root: &Path, plan: &str) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; if store.versions.is_empty() && store.rejected.is_empty() { return Ok(not_found(plan)); @@ -550,6 +596,7 @@ fn divergence_report(an: &Analysis) -> String { // --------------------------------------------------------------------------- fn cmd_history(root: &Path, plan: &str) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; if store.versions.is_empty() { return Ok(not_found(plan)); @@ -560,6 +607,15 @@ fn cmd_history(root: &Path, plan: &str) -> Result { let mut text = String::new(); let mut entries: Vec = Vec::new(); + // The goal is the Plan's human handle (CMP.DM-R12): lead with it, addressed + // by the PlanRef and the first head's goal. + let goal = an + .head + .iter() + .find_map(|h| evaluate(h).ok().map(|v| v.goal)) + .unwrap_or_default(); + text.push_str(&format!("{} {}\n", style::bold(&style::short(plan)), goal)); + for h in &an.head { let graph = match graph_from(&store, h) { Ok(g) => g, @@ -599,6 +655,7 @@ fn cmd_history(root: &Path, plan: &str) -> Result { let json = Json::obj(vec![ ("command", Json::str("history")), ("plan", Json::str(plan)), + ("goal", Json::str(&goal)), ("rationale", Json::arr(entries)), convergence_json(&c), ]); @@ -610,6 +667,7 @@ fn cmd_history(root: &Path, plan: &str) -> Result { // --------------------------------------------------------------------------- fn cmd_ready(root: &Path, plan: &str) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; if store.versions.is_empty() { return Ok(not_found(plan)); @@ -724,15 +782,23 @@ fn cmd_status(root: &Path) -> Result { for plan in &plans { let store = catalog::load_plan(root, plan)?; let an = chain::analyze(&store); + // The goal is the human handle (CMP.DM-R12): lead with it, not the hash. + let goal = an + .head + .iter() + .find_map(|h| evaluate(h).ok().map(|v| v.goal)) + .unwrap_or_default(); text.push_str(&format!( - "{} {} {}, {}\n", - style::bold(plan), + "{} {}\n {} {}, {}\n", + style::bold(&style::short(plan)), + goal, an.state(), style::count(store.versions.len(), "version"), style::count(an.head.len(), "head"), )); rows.push(Json::obj(vec![ ("plan", Json::str(plan)), + ("goal", Json::str(&goal)), ("state", Json::str(an.state())), ("versions", Json::num(store.versions.len() as i64)), ("heads", Json::num(an.head.len() as i64)), @@ -758,7 +824,7 @@ fn cmd_verify(root: &Path, plan: Option<&str>, all: bool) -> Result = Vec::new(); let mut text = String::new(); @@ -869,6 +935,7 @@ fn file_name(p: &Path) -> String { // --------------------------------------------------------------------------- fn cmd_repair(root: &Path, plan: &str) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; let an = chain::analyze(&store); @@ -968,6 +1035,7 @@ fn cmd_progress( note: Option<&str>, author: &str, ) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; let an = chain::analyze(&store); let (head, _v) = head_for_step(&an, step)?; @@ -1016,6 +1084,7 @@ fn cmd_evidence( attrs: &[(String, String)], author: &str, ) -> Result { + let plan = &resolve_plan(root, plan); let store = load(root, plan)?; let an = chain::analyze(&store); let (head, version) = head_for_step(&an, step)?; diff --git a/src/model.rs b/src/model.rs index 550427a..b22fa03 100644 --- a/src/model.rs +++ b/src/model.rs @@ -114,6 +114,17 @@ impl Version { /// dependency present, and no dependency cycle (which would make readiness /// unexplainable). These run against an evaluated module at commit time. pub fn validate(&self) -> Result<(), String> { + // A goal is required on every version and is the Plan's human handle + // (CMP.DM-R12, decision 0017). It is checked on the evaluated value, so a + // revision that inherits its predecessor's goal passes, and one that + // states an empty goal is refused. + if self.goal.trim().is_empty() { + return Err( + "a version must state a non-empty goal: it is the human handle for the \ + plan (CMP.DM-R12)" + .to_string(), + ); + } let mut seen: Vec<&str> = Vec::new(); for s in &self.steps { if seen.contains(&s.id.as_str()) { diff --git a/tests/acceptance.rs b/tests/acceptance.rs index b64d688..5fcb930 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -57,9 +57,8 @@ fn every_example_evaluates() { /// states only what it changed. #[test] fn reconciliation_carries_both_sides_forward() { - let p = Path::new( - "examples/two-machines/catalog/plans/pl_nested_groups/versions/003-037d5ddb9db7.ts", - ); + let p = + Path::new("examples/two-machines/catalog/plans/8e528ff9bc56/versions/003-037d5ddb9db7.ts"); let map = eval::eval_plan_file(p).unwrap(); let v = map.get(&std::fs::canonicalize(p).unwrap()).unwrap(); let names: Vec<&str> = v.steps.iter().map(|s| s.name.as_str()).collect(); @@ -103,6 +102,15 @@ fn run(root: &Path, cmd: Command) -> Result { }) } +/// The PlanRef a source files under (decision 0017): for an origin, the +/// hash-prefix of its own bytes. Revisions of it share this same PlanRef. +fn planref_of(source: &str) -> String { + compass::sha256::sha256_hex(source.as_bytes())[..12].to_string() +} + +/// Author `source` as a draft in the plan's versions dir (so a revision's sibling +/// import resolves), commit it — the CLI names no plan, deriving it (decision +/// 0017) — and return the freshly committed head filename. fn commit_module(root: &Path, plan: &str, source: &str) -> String { let vdir = catalog::versions_dir(root, plan); std::fs::create_dir_all(&vdir).unwrap(); @@ -114,7 +122,6 @@ fn commit_module(root: &Path, plan: &str, source: &str) -> String { root, Command::Commit { path: draft.clone(), - plan: Some(plan.into()), }, ) .unwrap_or_else(|e| panic!("commit failed: {e}")); @@ -139,44 +146,46 @@ fn e2e_start_commit_show_history_revise_reconcile() { let root = std::env::temp_dir().join(format!("compass-e2e-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let guard = Tmp(root.clone()); - let plan = "pl_demo"; - // start — scaffolds a runnable draft (CMP-R11). - run( - &root, - Command::Start { - plan: plan.into(), - goal: Some("Ship the widget".into()), - }, - ) - .unwrap(); - // `start` scaffolds a runnable draft in the plan's versions/ directory. - let scaffolded = std::fs::read_dir(catalog::versions_dir(&root, plan)) - .unwrap() - .filter_map(|e| e.ok()) - .any(|e| { - let n = e.file_name(); - let n = n.to_string_lossy(); - n.ends_with(".ts") && compass::model::parse_filename(&n).is_none() - }); - assert!(scaffolded, "start should scaffold an editable draft module"); - - // commit a real root (edit stands in for the operator editing the draft). + // The real origin the operator will author (edit stands in for editing the + // scaffold). Its PlanRef is derived from these bytes (decision 0017). let root_mod = r#"import { plan, step, evidence } from "compass" export const build = step({ work: "Build it", accept: evidence.test({ name: "t", status: "pass" }) }) export const ship = step({ work: "Ship it", dependsOn: [build], accept: evidence.review({ actor: "cos", verdict: "approved" }) }) export default plan({ author: "cos", goal: "Ship the widget", why: "It is time.", steps: [build, ship] }) "#; + let plan = &planref_of(root_mod); + + // start — scaffolds a runnable draft (CMP-R11), naming no plan (decision 0017). + run(&root, Command::Start { goal: None }).unwrap(); + // `start` scaffolds a runnable draft into the staging area, not a plan dir — + // the plan has no identity until its origin is committed. + let scaffolded = std::fs::read_dir(catalog::drafts_dir(&root)) + .unwrap() + .filter_map(|e| e.ok()) + .any(|e| e.file_name().to_string_lossy().ends_with(".ts")); + assert!(scaffolded, "start should scaffold an editable draft module"); + + // commit the origin: it files under its own hash, which is the PlanRef. let v1 = commit_module(&root, plan, root_mod); + assert_eq!( + &v1[4..16], + plan.as_str(), + "the origin's version-id and the PlanRef are the same hash (decision 0017)" + ); + assert!( + catalog::plan_dir(&root, plan).is_dir(), + "the origin files under its derived PlanRef {plan}" + ); // show + history + ready read the plan by evaluating it. - assert!(run(&root, Command::Show { plan: plan.into() }) + assert!(run(&root, Command::Show { plan: plan.clone() }) .unwrap() .text .contains("build")); - let hist = run(&root, Command::History { plan: plan.into() }).unwrap(); + let hist = run(&root, Command::History { plan: plan.clone() }).unwrap(); assert!(hist.text.contains("It is time")); - assert!(run(&root, Command::Ready { plan: plan.into() }) + assert!(run(&root, Command::Ready { plan: plan.clone() }) .unwrap() .text .contains("build")); @@ -188,7 +197,6 @@ export default plan({ author: "cos", goal: "Ship the widget", why: "It is time." &root, Command::Commit { path: vdir.join("again.ts"), - plan: Some(plan.into()), }, ) .unwrap(); @@ -206,7 +214,7 @@ export default prior.revise({{ "# ); let v2 = commit_module(&root, plan, &rev); - assert!(run(&root, Command::Show { plan: plan.into() }) + assert!(run(&root, Command::Show { plan: plan.clone() }) .unwrap() .text .contains("carefully")); @@ -233,7 +241,6 @@ export default prior.revise({{ author: "dev", why: "Add a docs step.", add: [doc &root, Command::Commit { path: vdir.join("b.ts"), - plan: Some(plan.into()), }, ) .unwrap(); @@ -411,24 +418,28 @@ fn a_cross_plan_reference_is_not_a_predecessor() { let _g = Tmp(root.clone()); catalog::init(&root).unwrap(); - // The other plan, with an admitted version. + // The other plan, filed under its own PlanRef (its origin hash). let dep_src = r#"import { plan, step, evidence } from "compass" export const seed = step({ work: "Seed work", accept: evidence.test({ status: "pass" }) }) export default plan({ author: "cos", goal: "dep", why: "the referenced plan", steps: [seed] }) "#; - let dep_v1 = admit(&root, "pl_dep", 1, dep_src); + let dep_plan = planref_of(dep_src); + let dep_v1 = admit(&root, &dep_plan, 1, dep_src); - // A first version of pl_main that references pl_dep's version cross-plan. - // The reference is real (it reads a value from the other plan's version) but - // is not a predecessor: this commit has no parent. + // A first version of the main plan that references the dep plan's version + // cross-plan, by its PlanRef directory. The reference is real (it reads a + // value from the other plan's version) but is not a predecessor: no parent. let main_src = format!( r#"import {{ plan, step, evidence }} from "compass" -import dep from "../../pl_dep/versions/{dep_v1}" +import dep from "../../{dep_plan}/versions/{dep_v1}" export const local = step({{ work: "Local, mirrors " + dep.steps.seed.work, accept: evidence.test({{ status: "pass" }}) }}) -export default plan({{ author: "cos", goal: "main", why: "references pl_dep cross-plan", steps: [local] }}) +export default plan({{ author: "cos", goal: "main", why: "references dep cross-plan", steps: [local] }}) "# ); - let vdir = catalog::versions_dir(&root, "pl_main"); + // The main plan has no predecessor, so its PlanRef is its own hash — author + // the draft in that dir so the cross-plan `../../` reference resolves. + let main_plan = planref_of(&main_src); + let vdir = catalog::versions_dir(&root, &main_plan); std::fs::create_dir_all(&vdir).unwrap(); let draft = vdir.join("draft.ts"); std::fs::write(&draft, &main_src).unwrap(); @@ -437,7 +448,6 @@ export default plan({{ author: "cos", goal: "main", why: "references pl_dep cros &root, Command::Commit { path: draft.clone(), - plan: Some("pl_main".into()), }, ) .unwrap_or_else(|e| panic!("cross-plan commit must succeed, not be rejected: {e}")); @@ -448,8 +458,9 @@ export default plan({{ author: "cos", goal: "main", why: "references pl_dep cros out.text ); - // The committed version records no parent, and is not an orphan. - let store = catalog::load_plan(&root, "pl_main").unwrap(); + // The committed version records no parent, is filed under its own PlanRef, + // and is not an orphan. + let store = catalog::load_plan(&root, &main_plan).unwrap(); assert_eq!(store.versions.len(), 1, "rejected: {:?}", store.rejected); assert!( store.versions[0].parents.is_empty(), @@ -615,3 +626,140 @@ fn the_sandbox_grants_no_dynamic_code_or_clock() { ); } } + +// ---- decision 0017: a Plan's identity is its origin ---- + +const DEMO_ORIGIN: &str = r#"import { plan, step, evidence } from "compass" +export const build = step({ work: "Build it", accept: evidence.test({ name: "t", status: "pass" }) }) +export default plan({ author: "cos", goal: "Ship the widget", why: "It is time.", steps: [build] }) +"#; + +/// An origin commit files under its own hash — the origin's version-id and the +/// PlanRef are the same hash — and a revision inherits that same PlanRef. +#[test] +fn an_origin_files_under_its_own_hash_and_a_revision_shares_the_planref() { + let root = std::env::temp_dir().join(format!("compass-0017-id-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let plan = &planref_of(DEMO_ORIGIN); + + let v1 = commit_module(&root, plan, DEMO_ORIGIN); + assert_eq!( + &v1[4..16], + plan.as_str(), + "an origin's version-id is its PlanRef" + ); + assert!(catalog::plan_dir(&root, plan).is_dir()); + + // A revision, authored as a sibling of its predecessor, inherits the Plan. + let rev = format!( + r#"import prior from "./{v1}" +export default prior.revise({{ author: "cos", why: "Reword.", edit: [prior.steps.build.with({{ work: "Build it, carefully" }})] }}) +"# + ); + let v2 = commit_module(&root, plan, &rev); + let store = catalog::load_plan(&root, plan).unwrap(); + assert_eq!( + store.versions.len(), + 2, + "the revision files under the same PlanRef as its origin: {:?}", + store.rejected + ); + let head = chain::analyze(&store) + .head + .into_iter() + .max_by_key(|a| a.seq) + .unwrap() + .clone(); + assert_eq!(head.path.file_name().unwrap().to_str().unwrap(), v2); + assert_eq!(head.seq, 2, "the revision is seq 2 of the same lineage"); +} + +/// A version placed in the wrong plan dir is rejected — never reinterpreted into +/// the Plan it was filed under — on the same terms as a content-hash mismatch. +#[test] +fn a_version_in_the_wrong_plan_dir_is_rejected() { + let root = std::env::temp_dir().join(format!("compass-0017-misfiled-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + catalog::init(&root).unwrap(); + + // File the origin under a directory that is not its own hash. The filename + // still matches the content (so this is not tampering), but the dir does not. + let wrong = "abcabcabcabc"; + assert_ne!(wrong, planref_of(DEMO_ORIGIN)); + admit(&root, wrong, 1, DEMO_ORIGIN); + + let store = catalog::load_plan(&root, wrong).unwrap(); + assert!( + store.versions.is_empty(), + "a misfiled version is not admitted" + ); + assert_eq!(store.rejected.len(), 1); + assert!( + store.rejected[0].reason.contains("misfiled"), + "the rejection must name the misfiling: {}", + store.rejected[0].reason + ); + + // verify surfaces it as a problem, so the plan does not read clean. + let out = run( + &root, + Command::Verify { + plan: Some(wrong.into()), + all: false, + }, + ) + .unwrap(); + assert_ne!(out.code, 0, "a misfiled version fails verification"); +} + +/// A commit whose goal is empty is refused, and nothing is recorded (CMP.DM-R12). +#[test] +fn a_commit_with_an_empty_goal_is_refused() { + let root = std::env::temp_dir().join(format!("compass-0017-goal-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + catalog::init(&root).unwrap(); + + let empty_goal = r#"import { plan, step, evidence } from "compass" +export const a = step({ work: "x", accept: evidence.test({ status: "pass" }) }) +export default plan({ author: "cos", goal: "", why: "w", steps: [a] }) +"#; + // An origin has no predecessor import, so it may be authored anywhere. + let draft = tmp_module(&root, "empty-goal.ts", empty_goal); + let err = match run(&root, Command::Commit { path: draft }) { + Ok(o) => panic!("an empty goal must be refused, got: {}", o.text), + Err(e) => e, + }; + assert!( + err.contains("goal") && err.contains("nothing was recorded"), + "an empty goal must be refused with nothing recorded: {err}" + ); + // The refusal recorded nothing: the plan dir does not exist. + assert!(!catalog::plan_dir(&root, &planref_of(empty_goal)).exists()); +} + +/// `status` and `show` present a Plan by its goal, the human handle, not by its +/// raw hash (CMP.DM-R12). +#[test] +fn status_and_show_display_the_goal() { + let root = std::env::temp_dir().join(format!("compass-0017-handle-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let plan = &planref_of(DEMO_ORIGIN); + commit_module(&root, plan, DEMO_ORIGIN); + + let status = run(&root, Command::Status).unwrap(); + assert!( + status.text.contains("Ship the widget"), + "status shows the goal: {}", + status.text + ); + let show = run(&root, Command::Show { plan: plan.clone() }).unwrap(); + assert!( + show.text.contains("Ship the widget"), + "show shows the goal: {}", + show.text + ); +} From 903b49283a9bebe0331bd0e4db618458250ea028 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:14:02 +0200 Subject: [PATCH 3/8] feat(compass): reference a Plan by goal in progress/evidence output Complete decision 0017's "goal is the human handle, surfaced wherever a Plan is referenced": the progress and evidence confirmation lines printed the raw PlanRef; show the goal instead (the version is still cited by its hash). Add an acceptance test asserting both commands render the goal. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cmd.rs | 8 +++++--- tests/acceptance.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/cmd.rs b/src/cmd.rs index 1264f08..8723e60 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1038,7 +1038,7 @@ fn cmd_progress( let plan = &resolve_plan(root, plan); let store = load(root, plan)?; let an = chain::analyze(&store); - let (head, _v) = head_for_step(&an, step)?; + let (head, v) = head_for_step(&an, step)?; let ekind = EventKind::parse(kind).ok_or_else(|| format!("unknown progress kind `{kind}`"))?; let event = Event { @@ -1055,12 +1055,13 @@ fn cmd_progress( attrs: vec![], }; let path = catalog::write_event(root, &event)?; + // Reference the Plan by its goal (the human handle), the version by its hash. let text = format!( "{} {} {} on {} (against {})\n", style::green("recorded"), kind, style::bold(step), - style::bold(plan), + style::bold(&v.goal), style::short(&head.hash) ); let json = Json::obj(vec![ @@ -1119,11 +1120,12 @@ fn cmd_evidence( }; let path = catalog::write_event(root, &event)?; + // Reference the Plan by its goal (the human handle), the version by its hash. let mut text = format!( "{} evidence {} on {} (against {})\n", style::green("recorded"), style::bold(step), - style::bold(plan), + style::bold(&version.goal), style::short(&head.hash) ); if let Some(w) = &warning { diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 5fcb930..702db72 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -763,3 +763,49 @@ fn status_and_show_display_the_goal() { show.text ); } + +/// `progress` and `evidence` reference the Plan by its goal (the human handle), +/// not the raw hash — the version is still cited by hash (CMP.DM-R12). +#[test] +fn progress_and_evidence_reference_the_plan_by_goal() { + let root = std::env::temp_dir().join(format!("compass-0017-record-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let _g = Tmp(root.clone()); + let plan = &planref_of(DEMO_ORIGIN); + commit_module(&root, plan, DEMO_ORIGIN); + + let prog = run( + &root, + Command::Progress { + plan: plan.clone(), + step: "build".into(), + kind: "start".into(), + note: None, + }, + ) + .unwrap(); + assert!( + prog.text.contains("Ship the widget"), + "progress references the plan by goal: {}", + prog.text + ); + + let ev = run( + &root, + Command::Evidence { + plan: plan.clone(), + step: "build".into(), + kind: "test".into(), + attrs: vec![ + ("name".into(), "t".into()), + ("status".into(), "pass".into()), + ], + }, + ) + .unwrap(); + assert!( + ev.text.contains("Ship the widget"), + "evidence references the plan by goal: {}", + ev.text + ); +} From ae88bf924caa99a29a7f5ee93c078b6c39f3a931 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:20:48 +0200 Subject: [PATCH 4/8] fix(vrs): renumber duplicate CMP.DM ids; fix 0017 cites; hash-dir example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DQ04 edit added CMP.DM-R11/R12 in the Identity section, colliding with the pre-existing Progress/acceptance R11/R12 — a duplicate-id bug that axe vrs check did not catch. Renumber the new pair to R17 (Plan identity) and R18 (goal), leaving the older Progress/Acceptance ids and the 07-evals CMP.DM-R12 citation valid. Update the impl comments that cited the new ids. Also: decision 0017 cited CMP.DM-R08 (Step identity = declared name) as forbidding location-as-identity; the no-location property is the PlanRef ontology entry — corrected. And the 06-api cross-plan example showed a pl_-named dir; under 0017 a plan segment is the origin hash, so the example now uses one and refers to the other plan by its goal. Co-Authored-By: Claude Opus 4.8 (1M context) EOF --- .../.decisions/0017-a-plans-identity-is-its-origin.md | 6 +++--- context/01-data-model/requirements.md | 4 ++-- context/06-api/spec.md | 10 ++++++---- src/catalog.rs | 2 +- src/cmd.rs | 6 +++--- src/model.rs | 4 ++-- tests/acceptance.rs | 6 +++--- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/context/.decisions/0017-a-plans-identity-is-its-origin.md b/context/.decisions/0017-a-plans-identity-is-its-origin.md index 519754a..d97ff8a 100644 --- a/context/.decisions/0017-a-plans-identity-is-its-origin.md +++ b/context/.decisions/0017-a-plans-identity-is-its-origin.md @@ -12,7 +12,7 @@ A Step's identity is the name it is declared under (0012). A Plan has no such declaration site — it is not an export in another module — so that mechanism does not transfer. Minting is gone. In its absence the implementation fell back on the catalog directory as the Plan's handle, which makes identity a filesystem -location: exactly what the ontology and CMP.DM-R08 forbid, and it means moving or +location: exactly what the ontology's PlanRef definition forbids, and it means moving or misfiling a version silently changes which Plan it belongs to. Three candidates were on the table: a declared name, a path segment, and the @@ -36,7 +36,7 @@ one value with two jobs and reintroduce an assertion that can be typed wrong, for a readability that `goal` already provides. So identity is the hash of the **origin** — the single predecessor-less version. -It is fully derived (CMP-R10), encodes no location (CMP.DM-R08), cannot collide, +It is fully derived (CMP-R10), encodes no location (its PlanRef ontology entry), cannot collide, and makes "the same Plan" a content fact: two versions belong to the same Plan iff they descend from the same origin. There is a clean invariant in it — the origin version's own identity *is* the PlanRef, since both are the hash of the @@ -56,7 +56,7 @@ needs a ref. | --- | --- | | Origin content hash | Fully derived, collision-free, location-independent, "same Plan" is a content fact; opaque, and unavailable until the origin is committed | | Name declared in `plan()` | Readable and idempotent; asserts an identity that `goal` already makes readable, and can be typed wrong | -| Catalog path segment | Simplest and matches a naive implementation; makes identity a location, which CMP.DM-R08 forbids, so a moved file changes identity | +| Catalog path segment | Simplest and matches a naive implementation; makes identity a location, which the PlanRef ontology entry forbids, so a moved file changes identity | ## Decision diff --git a/context/01-data-model/requirements.md b/context/01-data-model/requirements.md index 4d3128b..15ef61a 100644 --- a/context/01-data-model/requirements.md +++ b/context/01-data-model/requirements.md @@ -125,14 +125,14 @@ incidental, because without it a committed Step can be re-identified while every hash in the lineage stays constant. _refines: CMP-R02, CMP-R07._ -- **CMP.DM-R11 A Plan's identity is its origin.** A Plan is identified by the +- **CMP.DM-R17 A Plan's identity is its origin.** A Plan is identified by the content hash of its origin — the one version with no predecessor. It is derived, never declared and never minted, and encodes no location. Two versions are the same Plan when they share an origin. A version whose derived Plan disagrees with where it is filed is rejected, not reinterpreted. _refines: CMP-R10, CMP-R02._ -- **CMP.DM-R12 A goal is required and is the human handle.** Every version +- **CMP.DM-R18 A goal is required and is the human handle.** Every version states a goal, and the goal is what identifies a Plan to a person — surfaced wherever a Plan is listed or referenced, in place of its hash. Identity is derived and machine-facing; readability is the goal's job, so neither carries diff --git a/context/06-api/spec.md b/context/06-api/spec.md index 497b553..c1dfa9e 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -126,16 +126,18 @@ an open question (DQ08). Referring to another Plan's version is importing it. The import path reaches across the catalog's plan-then-versions layout, so a sibling plan is two levels -up — pop `versions/`, then the plan segment: +up — pop `versions/`, then the plan segment. That segment is the other Plan's +PlanRef, its origin hash (decision 0017), not a chosen name: ```ts -import release from "../../pl_release/versions/007-4d81f0a2.ts" +import release from "../../4d81f0a2b3c1/versions/007-4d81f0a2b3c1.ts" ``` The import is what makes the reference checkable rather than spelled — and it is why the other Plan must be present to evaluate this one. A machine that has not -received `pl_release` cannot read this Plan at all, and reports it as Unresolved -rather than showing an incomplete graph. The imported version is a reference, +received that Plan (the one whose goal is "cut the release branch") cannot read +this one at all, and reports it as Unresolved rather than showing an incomplete +graph. The imported version is a reference, not a predecessor: it does not become a parent of the importing version, and its absence-of-admission there is an error, not an uncommitted-predecessor. diff --git a/src/catalog.rs b/src/catalog.rs index 97126c1..46723ee 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -328,7 +328,7 @@ fn predecessor_prefixes( Ok(out) } -/// Reject versions misfiled under the wrong Plan (decision 0017, CMP.DM-R11). +/// Reject versions misfiled under the wrong Plan (decision 0017, CMP.DM-R17). /// /// A Plan's identity is the content hash of its origin. A version whose derived /// origin resolves to a PlanRef different from the directory it sits in is diff --git a/src/cmd.rs b/src/cmd.rs index 8723e60..789919c 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -228,7 +228,7 @@ fn cmd_start(root: &Path, goal: Option<&str>, author: &str) -> Result Result { let mut text = String::new(); let mut entries: Vec = Vec::new(); - // The goal is the Plan's human handle (CMP.DM-R12): lead with it, addressed + // The goal is the Plan's human handle (CMP.DM-R18): lead with it, addressed // by the PlanRef and the first head's goal. let goal = an .head @@ -782,7 +782,7 @@ fn cmd_status(root: &Path) -> Result { for plan in &plans { let store = catalog::load_plan(root, plan)?; let an = chain::analyze(&store); - // The goal is the human handle (CMP.DM-R12): lead with it, not the hash. + // The goal is the human handle (CMP.DM-R18): lead with it, not the hash. let goal = an .head .iter() diff --git a/src/model.rs b/src/model.rs index b22fa03..52c0901 100644 --- a/src/model.rs +++ b/src/model.rs @@ -115,13 +115,13 @@ impl Version { /// unexplainable). These run against an evaluated module at commit time. pub fn validate(&self) -> Result<(), String> { // A goal is required on every version and is the Plan's human handle - // (CMP.DM-R12, decision 0017). It is checked on the evaluated value, so a + // (CMP.DM-R18, decision 0017). It is checked on the evaluated value, so a // revision that inherits its predecessor's goal passes, and one that // states an empty goal is refused. if self.goal.trim().is_empty() { return Err( "a version must state a non-empty goal: it is the human handle for the \ - plan (CMP.DM-R12)" + plan (CMP.DM-R18)" .to_string(), ); } diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 702db72..2b34add 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -714,7 +714,7 @@ fn a_version_in_the_wrong_plan_dir_is_rejected() { assert_ne!(out.code, 0, "a misfiled version fails verification"); } -/// A commit whose goal is empty is refused, and nothing is recorded (CMP.DM-R12). +/// A commit whose goal is empty is refused, and nothing is recorded (CMP.DM-R18). #[test] fn a_commit_with_an_empty_goal_is_refused() { let root = std::env::temp_dir().join(format!("compass-0017-goal-{}", std::process::id())); @@ -741,7 +741,7 @@ export default plan({ author: "cos", goal: "", why: "w", steps: [a] }) } /// `status` and `show` present a Plan by its goal, the human handle, not by its -/// raw hash (CMP.DM-R12). +/// raw hash (CMP.DM-R18). #[test] fn status_and_show_display_the_goal() { let root = std::env::temp_dir().join(format!("compass-0017-handle-{}", std::process::id())); @@ -765,7 +765,7 @@ fn status_and_show_display_the_goal() { } /// `progress` and `evidence` reference the Plan by its goal (the human handle), -/// not the raw hash — the version is still cited by hash (CMP.DM-R12). +/// not the raw hash — the version is still cited by hash (CMP.DM-R18). #[test] fn progress_and_evidence_reference_the_plan_by_goal() { let root = std::env::temp_dir().join(format!("compass-0017-record-{}", std::process::id())); From 11be14864d8ad2806b03a6b26ccb7c08abf1f80e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:17:31 +0200 Subject: [PATCH 5/8] refactor(vocab): PlanRef -> PlanId; fold StepRef into Step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The *Ref terms were coined for the minted-reference design, where identities were opaque tokens you passed around. That design is gone: a Step's identity is its declared export name (0012) and a Plan's is its origin hash (0017) — intrinsic identities, not reference tokens. - StepRef is dropped as a standalone term. There is no ref artifact: a Step's identity is its name, and depending on a Step names the declaration (a language reference). Folded into the Step ontology entry. - PlanRef -> PlanId: it is an identity (a derived hash), not a pointer to elsewhere; a cross-Plan reference imports the other version. Propagated across the current layer (specs, api, cli, examples, code incl. derive_planref -> derive_planid). The superseded decision 0004 keeps the old terms as a historical record; the ontology's _Avoid_ lists PlanRef/StepRef so a reader of old records maps them. 130 lib + 15 acceptance, clippy/fmt/vrs green. Co-Authored-By: Claude Opus 4.8 (1M context) EOF --- .../0017-a-plans-identity-is-its-origin.md | 22 ++++++------ context/01-data-model/spec.md | 4 +-- context/02-artifacts/spec.md | 8 ++--- context/03-surface/spec.md | 4 +-- context/04-cli/spec.md | 4 +-- context/06-api/spec.md | 2 +- context/ontology.md | 36 +++++++++---------- context/open-questions.md | 2 +- examples/editorial-review/README.md | 2 +- examples/hypothesis-dies/README.md | 2 +- examples/two-machines/README.md | 2 +- src/catalog.rs | 32 ++++++++--------- src/cli.rs | 6 ++-- src/cmd.rs | 16 ++++----- src/model.rs | 6 ++-- src/readiness.rs | 2 +- 16 files changed, 74 insertions(+), 76 deletions(-) diff --git a/context/.decisions/0017-a-plans-identity-is-its-origin.md b/context/.decisions/0017-a-plans-identity-is-its-origin.md index d97ff8a..863defd 100644 --- a/context/.decisions/0017-a-plans-identity-is-its-origin.md +++ b/context/.decisions/0017-a-plans-identity-is-its-origin.md @@ -12,7 +12,7 @@ A Step's identity is the name it is declared under (0012). A Plan has no such declaration site — it is not an export in another module — so that mechanism does not transfer. Minting is gone. In its absence the implementation fell back on the catalog directory as the Plan's handle, which makes identity a filesystem -location: exactly what the ontology's PlanRef definition forbids, and it means moving or +location: exactly what the ontology's PlanId definition forbids, and it means moving or misfiling a version silently changes which Plan it belongs to. Three candidates were on the table: a declared name, a path segment, and the @@ -36,15 +36,15 @@ one value with two jobs and reintroduce an assertion that can be typed wrong, for a readability that `goal` already provides. So identity is the hash of the **origin** — the single predecessor-less version. -It is fully derived (CMP-R10), encodes no location (its PlanRef ontology entry), cannot collide, +It is fully derived (CMP-R10), encodes no location (its PlanId ontology entry), cannot collide, and makes "the same Plan" a content fact: two versions belong to the same Plan iff they descend from the same origin. There is a clean invariant in it — the -origin version's own identity *is* the PlanRef, since both are the hash of the +origin version's own identity *is* the PlanId, since both are the hash of the same bytes. The one objection DQ04 itself raised — a hash is unavailable before the first version exists, which seems to collide with CMP-R11 (starting must be trivial) — -does not hold. Authoring a Plan references nothing by PlanRef: a first version +does not hold. Authoring a Plan references nothing by PlanId: a first version declares steps and a goal, imports only `compass`, and names no plan identity. The ref comes into being when the origin is committed, which is exactly when a Plan first exists. Starting stays a single command; the author never types or @@ -56,15 +56,15 @@ needs a ref. | --- | --- | | Origin content hash | Fully derived, collision-free, location-independent, "same Plan" is a content fact; opaque, and unavailable until the origin is committed | | Name declared in `plan()` | Readable and idempotent; asserts an identity that `goal` already makes readable, and can be typed wrong | -| Catalog path segment | Simplest and matches a naive implementation; makes identity a location, which the PlanRef ontology entry forbids, so a moved file changes identity | +| Catalog path segment | Simplest and matches a naive implementation; makes identity a location, which the PlanId ontology entry forbids, so a moved file changes identity | ## Decision -A Plan's identity, its PlanRef, is the content hash of its origin version — the +A Plan's identity, its PlanId, is the content hash of its origin version — the version with no predecessor. It is derived, never declared and never minted. Two versions are the same Plan when they share an origin. The catalog files a -Plan under its PlanRef, and a version whose derived Plan does not match where it +Plan under its PlanId, and a version whose derived Plan does not match where it is filed is rejected rather than reinterpreted, on the same terms as a version whose content does not match its own name. @@ -75,13 +75,13 @@ other's job. ## Consequences -- The PlanRef is not known until the origin is committed. This does not affect - starting or authoring, which reference no PlanRef; it affects only how a Plan +- The PlanId is not known until the origin is committed. This does not affect + starting or authoring, which reference no PlanId; it affects only how a Plan is addressed afterwards, where `goal` is the readable handle and the hash is the exact one. -- The origin version's identity and the PlanRef are the same hash. A Plan is, +- The origin version's identity and the PlanId are the same hash. A Plan is, precisely, its first stated intent. -- Cross-plan references resolve to a PlanRef and so are content-addressed; their +- Cross-plan references resolve to a PlanId and so are content-addressed; their import paths are opaque, which is acceptable because they are machine-written. - Renaming is not an operation. A Plan cannot be renamed because it was never named; its `goal` can be revised like any other intent, and its identity is diff --git a/context/01-data-model/spec.md b/context/01-data-model/spec.md index da73dcb..fe86565 100644 --- a/context/01-data-model/spec.md +++ b/context/01-data-model/spec.md @@ -6,10 +6,10 @@ Realizes [requirements.md](./requirements.md). Storage is specified in ## Plan A Plan is a lineage of Versions plus the Progress recorded against them. It is -named by a `PlanRef` — the content hash of its origin, the one version with no +named by a `PlanId` — the content hash of its origin, the one version with no predecessor (decision 0017). Identity is derived from the origin, so two versions are the same Plan iff they share one; the origin's own identity is the -PlanRef. The human handle for a Plan is its required `goal`, not the PlanRef. +PlanId. The human handle for a Plan is its required `goal`, not the PlanId. ## Version diff --git a/context/02-artifacts/spec.md b/context/02-artifacts/spec.md index 186a72e..2338da5 100644 --- a/context/02-artifacts/spec.md +++ b/context/02-artifacts/spec.md @@ -6,14 +6,14 @@ Realizes [requirements.md](./requirements.md). The logical model it stores is in ## Layout ```text -catalog/plans//versions/-.ts immutable, mode 0444 -catalog/plans//events/-. append-only +catalog/plans//versions/-.ts immutable, mode 0444 +catalog/plans//events/-. append-only ``` -`` is the Plan's identity: the content hash of its origin version +`` is the Plan's identity: the content hash of its origin version (decision 0017). A Plan is filed under it; it is derived, not chosen, so no two Plans collide and no Plan is named. A version whose origin resolves to a -different PlanRef than the directory it sits in is rejected, on the same terms as +different PlanId than the directory it sits in is rejected, on the same terms as a version whose content does not match its own name — a misfiled version is never reinterpreted into the Plan it was filed under. diff --git a/context/03-surface/spec.md b/context/03-surface/spec.md index 6cbd401..aa6f42f 100644 --- a/context/03-surface/spec.md +++ b/context/03-surface/spec.md @@ -5,8 +5,8 @@ Realizes [requirements.md](./requirements.md). ## Shape ```text -read(PlanRef) -> PlanView | NotFound | Unresolved | Stopped -ready(PlanRef) -> Readiness | NotFound | Unresolved | Stopped +read(PlanId) -> PlanView | NotFound | Unresolved | Stopped +ready(PlanId) -> Readiness | NotFound | Unresolved | Stopped mutate(Mutation) -> Receipt | Rejected ``` diff --git a/context/04-cli/spec.md b/context/04-cli/spec.md index 6c81251..c4c238c 100644 --- a/context/04-cli/spec.md +++ b/context/04-cli/spec.md @@ -41,9 +41,9 @@ with a different message. One says "this is already committed"; the other says "this changes nothing." Rendering them alike would hide which happened. Committing an origin — a module with no predecessor — brings a Plan into being, -and its identity is derived then: the PlanRef is the hash of that origin +and its identity is derived then: the PlanId is the hash of that origin (decision 0017). The operator names nothing. Afterwards a Plan is addressed by -its `goal` where a person is reading and by its PlanRef where exactness is +its `goal` where a person is reading and by its PlanId where exactness is needed; a command that reports a Plan shows the `goal`, not the hash. Verification and repair are separate commands. Verification is safe to run diff --git a/context/06-api/spec.md b/context/06-api/spec.md index c1dfa9e..caff915 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -127,7 +127,7 @@ an open question (DQ08). Referring to another Plan's version is importing it. The import path reaches across the catalog's plan-then-versions layout, so a sibling plan is two levels up — pop `versions/`, then the plan segment. That segment is the other Plan's -PlanRef, its origin hash (decision 0017), not a chosen name: +PlanId, its origin hash (decision 0017), not a chosen name: ```ts import release from "../../4d81f0a2b3c1/versions/007-4d81f0a2b3c1.ts" diff --git a/context/ontology.md b/context/ontology.md index d5aff91..501d9cb 100644 --- a/context/ontology.md +++ b/context/ontology.md @@ -8,7 +8,7 @@ _Avoid_: planner, task runner, issue tracker **Plan**: Durable authored intent for one goal: an acceptance contract plus a dependency -graph of Steps. A Plan is referenced by a `PlanRef`. A Plan is never edited; it +graph of Steps. A Plan is identified by a `PlanId`. A Plan is never edited; it is revised, which produces a new Plan Version. _Avoid_: ticket, issue, epic, backlog, board @@ -97,26 +97,24 @@ _Avoid_: rebase, conflict resolution, merge commit, fixup **Step**: A stable unit of intended work within a Plan, carrying dependencies, acceptance -criteria, and lifecycle. Referenced by a `StepRef`. -_Avoid_: task row, checklist item, ephemeral list index - -**StepRef**: -A Step's identity: the name it is declared under, qualified by its Plan. It is -authored rather than minted, and is independent of the Step's content, so it -survives a rewording of the same intended work. It is not opaque — a reader can -read it — and it is not invented at a use site, because a dependency names the -declaration rather than spelling a reference. It is never reused after the Step -is retired, and a Step declared without a name has no identity and is refused. -_Avoid_: minted id, content hash, array index, title slug, opaque token - -**PlanRef**: +criteria, and lifecycle. **Its identity is the name it is declared under**, +qualified by its Plan: authored rather than minted, and independent of the +Step's content, so it survives a rewording of the same intended work. The name +is not opaque and not a separate handle — depending on a Step *names the +declaration* (a language reference), so there is no identifier to invent or +mistype. A name is never reused after the Step is retired, and a Step declared +without a name has no identity and is refused. +_Avoid_: task row, checklist item, ephemeral list index, StepRef, minted id, opaque token + +**PlanId**: A Plan's identity: the content hash of its origin — the single predecessor-less version. It is derived, never declared and never minted, and encodes no filesystem, database, transport, or host location. Two versions are the same -Plan when they share an origin; the origin version's own identity and the -PlanRef are the same hash. It is machine-facing; the human handle for a Plan is -its `goal`. -_Avoid_: plan path, catalog path, file name, plan name, declared id +Plan when they share an origin; the origin version's own identity and the PlanId +are the same hash. It is machine-facing; the human handle for a Plan is its +`goal`. It is an identity, not a pointer — a cross-Plan reference imports the +other Plan's version rather than spelling this. +_Avoid_: plan path, catalog path, file name, plan name, declared id, PlanRef, reference **Catalog**: The on-disk tree of Plans. Discovery is content-based: the tree is walked and @@ -175,6 +173,6 @@ _Avoid_: log acknowledgement, observation id **Observation**: An operational fact emitted by a surrounding system after a Compass mutation -succeeds. It may reference a Receipt, PlanRef, or StepRef, but never becomes +succeeds. It may reference a Receipt, a PlanId, or a Step by its name, but never becomes Compass state. _Avoid_: progress authority, completion record diff --git a/context/open-questions.md b/context/open-questions.md index 942f0e4..56c48f2 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -40,7 +40,7 @@ it. [decision 0017](./.decisions/0017-a-plans-identity-is-its-origin.md). A Plan's identity is the content hash of its origin (predecessor-less) version — derived, never declared or minted, encoding no location. Two versions are the same Plan -when they share an origin, and the origin's own identity is the PlanRef. Human +when they share an origin, and the origin's own identity is the PlanId. Human readability is carried by the required `goal`, not by the identity, so the identity need not be readable and is fully derived. diff --git a/examples/editorial-review/README.md b/examples/editorial-review/README.md index ad38e74..c062e02 100644 --- a/examples/editorial-review/README.md +++ b/examples/editorial-review/README.md @@ -51,7 +51,7 @@ and says so. ## Files -This plan has no name. Its identity — its PlanRef — is the content hash of its +This plan has no name. Its identity — its PlanId — is the content hash of its origin, the first version (decision 0017), so the plan directory is `cfe4f8d721d2`, the same hash the `001` file carries. To a person it is its goal, "Publish a defensible comparison of agent-memory tools"; the hash is only for diff --git a/examples/hypothesis-dies/README.md b/examples/hypothesis-dies/README.md index 2f84a28..3aad484 100644 --- a/examples/hypothesis-dies/README.md +++ b/examples/hypothesis-dies/README.md @@ -49,7 +49,7 @@ wrong. ## Files -This plan has no name. Its identity — its PlanRef — is the content hash of its +This plan has no name. Its identity — its PlanId — is the content hash of its origin, the first version (decision 0017), so the plan directory is `634e2a7c458b`, the same hash the `001` file carries. To a person it is its goal, "CI builds finish under 10 minutes"; the hash is only for exactness. diff --git a/examples/two-machines/README.md b/examples/two-machines/README.md index acc2844..ea0ef0f 100644 --- a/examples/two-machines/README.md +++ b/examples/two-machines/README.md @@ -56,7 +56,7 @@ added. ## Files -This plan has no name. Its identity — its PlanRef — is the content hash of its +This plan has no name. Its identity — its PlanId — is the content hash of its origin, the shared base `001` (decision 0017), so the plan directory is `8e528ff9bc56`, the same hash the `001` file carries. Both `002` sides descend from that origin, so they are the same Plan. To a person it is its goal, "Nested diff --git a/src/catalog.rs b/src/catalog.rs index 46723ee..948e343 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,8 +1,8 @@ //! The Catalog: on-disk storage and deliberate admission. //! //! ```text -//! /plans//versions/-.ts immutable, mode 0444 -//! /plans//events/-.cmp append-only +//! /plans//versions/-.ts immutable, mode 0444 +//! /plans//events/-.cmp append-only //! ``` //! //! ## Admission (02-artifacts) @@ -132,7 +132,7 @@ pub fn events_dir(root: &Path, plan: &str) -> PathBuf { } /// Where `start` scaffolds a draft before its identity is known. It is a sibling /// of `plans/`, never under it, so `list_plans` never mistakes a draft for a Plan -/// — a draft has no PlanRef until it is committed (decision 0017). +/// — a draft has no PlanId until it is committed (decision 0017). pub fn drafts_dir(root: &Path) -> PathBuf { root.join("drafts") } @@ -148,7 +148,7 @@ pub fn exists(root: &Path) -> bool { plans_dir(root).is_dir() } -/// Every PlanRef with a directory in the catalog, sorted. +/// Every PlanId with a directory in the catalog, sorted. pub fn list_plans(root: &Path) -> Result, String> { let dir = plans_dir(root); if !dir.is_dir() { @@ -331,7 +331,7 @@ fn predecessor_prefixes( /// Reject versions misfiled under the wrong Plan (decision 0017, CMP.DM-R17). /// /// A Plan's identity is the content hash of its origin. A version whose derived -/// origin resolves to a PlanRef different from the directory it sits in is +/// origin resolves to a PlanId different from the directory it sits in is /// rejected — never reinterpreted into the Plan it was filed under — on the same /// terms as a version whose content hash disagrees with its own filename. /// @@ -348,7 +348,7 @@ fn reject_misfiled(store: &mut PlanStore, plan: &str) { .collect(); let present: HashSet = store.versions.iter().map(|a| a.hash.clone()).collect(); - // The origin PlanRef of a version, or None when an ancestor is absent (an + // The origin PlanId of a version, or None when an ancestor is absent (an // orphan, whose Plan cannot be confirmed) or the lineage forms a cycle. let origin_prefix = |start: &str| -> Option { let mut cur = start.to_string(); @@ -385,16 +385,16 @@ fn reject_misfiled(store: &mut PlanStore, plan: &str) { store.versions = kept; } -/// Derive a version's PlanRef from its authored bytes (decision 0017). +/// Derive a version's PlanId from its authored bytes (decision 0017). /// /// A Plan's identity is the content hash of its origin — the single /// predecessor-less version. An origin (a module that imports no predecessor) is -/// its own PlanRef: the hash of its bytes, the same hash its version filename +/// its own PlanId: the hash of its bytes, the same hash its version filename /// carries. A revision inherits its Plan from its predecessor: its origin is /// found by walking the predecessor imports back to the predecessor-less version, /// and hashing that. The operator names nothing; identity is derived, and the /// prefix width matches the version filenames' for consistency. -pub fn derive_planref(path: &Path, source: &[u8]) -> Result { +pub fn derive_planid(path: &Path, source: &[u8]) -> Result { let src = std::str::from_utf8(source) .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; let origin_bytes = match sibling_predecessor_paths(path, src)?.into_iter().next() { @@ -562,16 +562,16 @@ export const a = step({ work: "do a", accept: evidence.test({ status: "pass" }) export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) "#; - /// The PlanRef a module files under: the hash-prefix of its own bytes, for an + /// The PlanId a module files under: the hash-prefix of its own bytes, for an /// origin (decision 0017). - fn planref_of(source: &str) -> String { + fn planid_of(source: &str) -> String { crate::sha256::sha256_hex(source.as_bytes())[..crate::model::HASH_PREFIX_LEN].to_string() } #[test] fn admits_a_version_by_source_byte_hash() { let s = Scratch::new("admit"); - let plan = planref_of(ROOT_MODULE); + let plan = planid_of(ROOT_MODULE); let (path, hash, created) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(created); @@ -587,7 +587,7 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) #[test] fn identical_content_is_a_no_op() { let s = Scratch::new("noop"); - let plan = planref_of(ROOT_MODULE); + let plan = planid_of(ROOT_MODULE); let (_, _, first) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); let (_, _, second) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(first); @@ -597,7 +597,7 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) #[test] fn tampered_bytes_are_rejected() { let s = Scratch::new("tamper"); - let plan = planref_of(ROOT_MODULE); + let plan = planid_of(ROOT_MODULE); let (path, _, _) = write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); make_writable_recursive(&s.root).unwrap(); fs::write(&path, b"import x from 'y'\n").unwrap(); @@ -613,7 +613,7 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) fn a_misfiled_version_is_rejected() { let s = Scratch::new("misfiled"); let wrong = "999999999999"; - assert_ne!(wrong, planref_of(ROOT_MODULE)); + assert_ne!(wrong, planid_of(ROOT_MODULE)); let (path, _, _) = write_version(&s.root, wrong, 1, ROOT_MODULE.as_bytes()).unwrap(); assert!(path.exists()); let store = load_plan(&s.root, wrong).unwrap(); @@ -628,7 +628,7 @@ export default plan({ author: "cos", goal: "Ship", why: "start", steps: [a] }) #[test] fn a_version_evaluates_to_its_intent() { let s = Scratch::new("eval"); - let plan = planref_of(ROOT_MODULE); + let plan = planid_of(ROOT_MODULE); write_version(&s.root, &plan, 1, ROOT_MODULE.as_bytes()).unwrap(); let store = load_plan(&s.root, &plan).unwrap(); let v = evaluate(&store.versions[0]).unwrap(); diff --git a/src/cli.rs b/src/cli.rs index e48d80e..63ddb67 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,7 +21,7 @@ pub enum Command { Start { goal: Option, }, - /// Evaluate a module and store it under its derived PlanRef (decision 0017). + /// Evaluate a module and store it under its derived PlanId (decision 0017). Commit { path: PathBuf, }, @@ -321,7 +321,7 @@ pub fn help(topic: Option<&str>) -> String { Some("commit") => "\ compass commit - Evaluate a module and store it under its derived PlanRef (decision 0017). + Evaluate a module and store it under its derived PlanId (decision 0017). A version is the authored module, stored unchanged (decision 0014); a Plan's identity is the content hash of its origin, derived here — you name nothing. @@ -372,7 +372,7 @@ usage: compass [options] evidence k=v record evidence acceptance evaluates version build identity -A is addressed by its PlanRef (the origin's hash) or, when unambiguous, +A is addressed by its PlanId (the origin's hash) or, when unambiguous, by its goal. A Plan is never named: its identity is derived (decision 0017). global options: diff --git a/src/cmd.rs b/src/cmd.rs index 789919c..2c3bdb9 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -106,8 +106,8 @@ fn load(root: &Path, plan: &str) -> Result { catalog::load_plan(root, plan) } -/// Resolve a Plan address to its PlanRef directory (decision 0017). A Plan is -/// addressed by its PlanRef — the origin's hash, which is the directory name — so +/// Resolve a Plan address to its PlanId directory (decision 0017). A Plan is +/// addressed by its PlanId — the origin's hash, which is the directory name — so /// that always works. As a convenience it also resolves an unambiguous hash /// prefix, and, failing that, an exact `goal` match when it is unique. Exactness /// is the hash; goal-resolution is a nicety and never guesses. @@ -115,12 +115,12 @@ fn resolve_plan(root: &Path, addr: &str) -> String { if !catalog::exists(root) { return addr.to_string(); } - // Exact PlanRef: a directory by that name. + // Exact PlanId: a directory by that name. if catalog::plan_dir(root, addr).is_dir() { return addr.to_string(); } let plans = catalog::list_plans(root).unwrap_or_default(); - // A unique hash-prefix of a PlanRef. + // A unique hash-prefix of a PlanId. let prefix_hits: Vec<&String> = plans.iter().filter(|p| p.starts_with(addr)).collect(); if prefix_hits.len() == 1 { return prefix_hits[0].clone(); @@ -222,7 +222,7 @@ fn cmd_version() -> Output { fn cmd_start(root: &Path, goal: Option<&str>, author: &str) -> Result { catalog::init(root)?; - // A draft has no PlanRef yet — identity is derived from the origin at commit + // A draft has no PlanId yet — identity is derived from the origin at commit // (decision 0017) — so it is scaffolded into a staging area, not a plan dir. let dir = catalog::drafts_dir(root); std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; @@ -295,9 +295,9 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; // A Plan's identity is derived from its origin (decision 0017): the operator - // names nothing. An origin is its own PlanRef; a revision inherits its Plan + // names nothing. An origin is its own PlanId; a revision inherits its Plan // from the predecessor it descends from. - let plan = catalog::derive_planref(path, &source)?; + let plan = catalog::derive_planid(path, &source)?; // Evaluate the authored module (imports resolve at its location). let map = crate::eval::eval_plan_file(path).map_err(|e| { @@ -608,7 +608,7 @@ fn cmd_history(root: &Path, plan: &str) -> Result { let mut entries: Vec = Vec::new(); // The goal is the Plan's human handle (CMP.DM-R18): lead with it, addressed - // by the PlanRef and the first head's goal. + // by the PlanId and the first head's goal. let goal = an .head .iter() diff --git a/src/model.rs b/src/model.rs index 52c0901..62f2f74 100644 --- a/src/model.rs +++ b/src/model.rs @@ -21,12 +21,12 @@ pub const HASH_PREFIX_LEN: usize = 12; /// A unit of intended work within a Plan. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Step { - /// StepRef — the name the step was declared under (decision 0012). + /// Identity — the name the step was declared under (decision 0012). pub id: String, pub work: String, - /// StepRefs this step depends on, sorted. + /// Names of the steps this step depends on, sorted. pub depends_on: Vec, - /// The StepRef this step replaces, when intended work changed identity. + /// The step this replaces, by name,, when intended work changed identity. pub supersedes: Option, /// Machine-checkable acceptance (decision 0006). pub accept: Pred, diff --git a/src/readiness.rs b/src/readiness.rs index d46557e..01dae1b 100644 --- a/src/readiness.rs +++ b/src/readiness.rs @@ -58,7 +58,7 @@ pub struct StepReadiness { pub state: StepState, /// Why the step is in this state. Never empty. pub reason: String, - /// StepRefs whose acceptance is holding this step back. + /// Steps whose acceptance is holding this step back. pub blocked_by: Vec, /// The acceptance criterion, canonically rendered. pub accept: String, From 68f0ad38211ead9ccd868a7ecba9a2625c2eda2e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:24:56 +0200 Subject: [PATCH 6/8] docs(ontology): group terms under thematic headings The 22-term list was flat. Group under six headings so the structure is legible without reading every entry: the tool; intent (Plan, PlanId, Version, Revision, Rationale, Step); lineage and its states (Head, Divergence, Reconciliation, Orphan, Unresolved); reading, storage, and replication; execution record; change surface and composition. Term text unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/ontology.md | 122 ++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/context/ontology.md b/context/ontology.md index 501d9cb..9099246 100644 --- a/context/ontology.md +++ b/context/ontology.md @@ -1,17 +1,31 @@ # Ontology: Compass +## The tool + **Compass**: The tool. It owns durable planning intent and the accepted execution record for that intent. It does not own coordination identity, messaging, presence, process supervision, or operational accounting. _Avoid_: planner, task runner, issue tracker +## Intent + **Plan**: Durable authored intent for one goal: an acceptance contract plus a dependency graph of Steps. A Plan is identified by a `PlanId`. A Plan is never edited; it is revised, which produces a new Plan Version. _Avoid_: ticket, issue, epic, backlog, board +**PlanId**: +A Plan's identity: the content hash of its origin — the single predecessor-less +version. It is derived, never declared and never minted, and encodes no +filesystem, database, transport, or host location. Two versions are the same +Plan when they share an origin; the origin version's own identity and the PlanId +are the same hash. It is machine-facing; the human handle for a Plan is its +`goal`. It is an identity, not a pointer — a cross-Plan reference imports the +other Plan's version rather than spelling this. +_Avoid_: plan path, catalog path, file name, plan name, declared id, PlanRef, reference + **Plan Version**: An immutable snapshot of a Plan's structural intent, authored as a module and stored exactly as authored. It carries a Rationale, its author, and imports each @@ -35,14 +49,6 @@ dropping a Step is not something Compass refuses but something a revision cannot say. _Avoid_: patch, diff, regeneration, overwrite -**Evaluation**: -Running a Plan Version, and transitively everything it imports, to obtain what -the Plan says. Reading is evaluation — there is no second stored form to consult -instead — so reading a replicated Plan runs code authored on another machine. -Evaluation holds no capability it was not explicitly given, and is bounded in -time and memory. -_Avoid_: parsing, loading, rendering, interpretation - **Rationale**: The required statement on every Plan Version explaining why intent changed. It is the durable planning record: the artifact is the plan, the value is the @@ -51,6 +57,19 @@ respect that matters — it is attached to a document whose Steps have identity, so a reason can be tied to a unit of work rather than to a range of bytes. _Avoid_: changelog entry, status note +**Step**: +A stable unit of intended work within a Plan, carrying dependencies, acceptance +criteria, and lifecycle. **Its identity is the name it is declared under**, +qualified by its Plan: authored rather than minted, and independent of the +Step's content, so it survives a rewording of the same intended work. The name +is not opaque and not a separate handle — depending on a Step *names the +declaration* (a language reference), so there is no identifier to invent or +mistype. A name is never reused after the Step is retired, and a Step declared +without a name has no identity and is refused. +_Avoid_: task row, checklist item, ephemeral list index, StepRef, minted id, opaque token + +## Lineage and its states + **Head**: The frontier of a Plan: the set of Plan Versions with no successor, derived by walking the chain. Ordinarily this set has one member and Head reads as "the @@ -73,6 +92,12 @@ operators learn to ignore the report. Only an open Divergence asks anything of anyone. _Avoid_: conflict, collision, fork +**Reconciliation**: +A Plan Version naming more than one predecessor, resolving a Divergence by +stating the reconciled intent and why. It is an ordinary Plan Version in every +other respect, and is itself capable of diverging. +_Avoid_: rebase, conflict resolution, merge commit, fixup + **Orphan**: A Plan Version whose predecessor is not present locally. Distinct from Divergence, which it superficially resembles: divergent versions share a @@ -89,32 +114,15 @@ Ordinarily it means replication has not delivered the import yet, and it is repaired by waiting; it is permanent if the import was never committed. _Avoid_: orphan, broken plan, missing parent -**Reconciliation**: -A Plan Version naming more than one predecessor, resolving a Divergence by -stating the reconciled intent and why. It is an ordinary Plan Version in every -other respect, and is itself capable of diverging. -_Avoid_: rebase, conflict resolution, merge commit, fixup - -**Step**: -A stable unit of intended work within a Plan, carrying dependencies, acceptance -criteria, and lifecycle. **Its identity is the name it is declared under**, -qualified by its Plan: authored rather than minted, and independent of the -Step's content, so it survives a rewording of the same intended work. The name -is not opaque and not a separate handle — depending on a Step *names the -declaration* (a language reference), so there is no identifier to invent or -mistype. A name is never reused after the Step is retired, and a Step declared -without a name has no identity and is refused. -_Avoid_: task row, checklist item, ephemeral list index, StepRef, minted id, opaque token +## Reading, storage, and replication -**PlanId**: -A Plan's identity: the content hash of its origin — the single predecessor-less -version. It is derived, never declared and never minted, and encodes no -filesystem, database, transport, or host location. Two versions are the same -Plan when they share an origin; the origin version's own identity and the PlanId -are the same hash. It is machine-facing; the human handle for a Plan is its -`goal`. It is an identity, not a pointer — a cross-Plan reference imports the -other Plan's version rather than spelling this. -_Avoid_: plan path, catalog path, file name, plan name, declared id, PlanRef, reference +**Evaluation**: +Running a Plan Version, and transitively everything it imports, to obtain what +the Plan says. Reading is evaluation — there is no second stored form to consult +instead — so reading a replicated Plan runs code authored on another machine. +Evaluation holds no capability it was not explicitly given, and is bounded in +time and memory. +_Avoid_: parsing, loading, rendering, interpretation **Catalog**: The on-disk tree of Plans. Discovery is content-based: the tree is walked and @@ -122,13 +130,6 @@ files that are Plan Versions are processed, regardless of their path. Path segments may supply defaults, but content wins. _Avoid_: database, index, registry -**Retired**: -A declared state marking a Plan or Step as decommissioned. Retirement is always -authored content carried forward by every later version, never a file deletion -and never an omission, because the Catalog replicates as a union with no deletes -and because a revision has no way to omit a Step in the first place. -_Avoid_: delete, archive, remove - **Index**: A machine-local cache holding the evaluated form of a version, keyed by that version's content hash. It exists because reading is evaluation and evaluation @@ -138,19 +139,12 @@ demand, it is never replicated, and there is nothing to invalidate — a changed module is a different hash and therefore a different key. _Avoid_: database, source of truth, projection, materialized view -**Progress Event**: -An append-only record of execution against a Step: start, update, handoff, -completion, evidence. Progress Events never alter structural intent and never -create a Plan Version. Unlike a version, a Progress Event is inert data: it is -read without being evaluated, and nothing in the progress layer executes. -_Avoid_: status field, state column, mutable progress - -**Plan Surface**: -The transport-neutral boundary for Compass queries and mutations, and the only -sanctioned way to change a Plan. It applies a mutation and returns a stable -Receipt. A repeated mutation is the same mutation when it carries the same -authored source, which yields the same identity and therefore one version. -_Avoid_: port, API, event emitter, shared-files adapter +**Retired**: +A declared state marking a Plan or Step as decommissioned. Retirement is always +authored content carried forward by every later version, never a file deletion +and never an omission, because the Catalog replicates as a union with no deletes +and because a revision has no way to omit a Step in the first place. +_Avoid_: delete, archive, remove **Convergence**: Whether the local catalog has received everything its peers have sent. It is a @@ -159,6 +153,15 @@ many versions a Plan should have, so completeness cannot be read from the data. A query answered before convergence may be answered from stale intent. _Avoid_: sync status, freshness, consistency +## Execution record + +**Progress Event**: +An append-only record of execution against a Step: start, update, handoff, +completion, evidence. Progress Events never alter structural intent and never +create a Plan Version. Unlike a version, a Progress Event is inert data: it is +read without being evaluated, and nothing in the progress layer executes. +_Avoid_: status field, state column, mutable progress + **Readiness**: The Plan-derived answer to what work is available now, computed from the Step graph at Head, accepted progress, and gates, together with an explanation of @@ -166,6 +169,15 @@ which dependencies and gates are unsatisfied. An answer without its explanation is not Readiness. _Avoid_: queue, backlog, todo list, next action +## Change surface and composition + +**Plan Surface**: +The transport-neutral boundary for Compass queries and mutations, and the only +sanctioned way to change a Plan. It applies a mutation and returns a stable +Receipt. A repeated mutation is the same mutation when it carries the same +authored source, which yields the same identity and therefore one version. +_Avoid_: port, API, event emitter, shared-files adapter + **Receipt**: The stable result of an accepted mutation, bound to its affected references and resulting Plan Version. @@ -173,6 +185,6 @@ _Avoid_: log acknowledgement, observation id **Observation**: An operational fact emitted by a surrounding system after a Compass mutation -succeeds. It may reference a Receipt, a PlanId, or a Step by its name, but never becomes -Compass state. +succeeds. It may reference a Receipt, a PlanId, or a Step by its name, but never +becomes Compass state. _Avoid_: progress authority, completion record From eb7ffe81b20c1a2424c7a5a77a4feff1dceb012b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:20:43 +0200 Subject: [PATCH 7/8] =?UTF-8?q?docs(compass):=20refine=20ontology=20?= =?UTF-8?q?=E2=80=94=20collapse=20write=20surface=20to=20Commit,=20rename?= =?UTF-8?q?=20predecessor=E2=86=92parent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interview-resolved ontology-refinement pass: - Surface cluster: drop Plan Surface, Receipt, Observation as terms. Anchor on Commit as the single write act; the write surface has two acts (Commit → Plan Version, Progress Event → append), not one Mutation umbrella. The Plan Surface invariant survives as a property; Receipt's role is the version hash itself; Observation's guarantee relocates to 05-integrations. - Add Goal, Acceptance, Evidence as terms. Strike phantom Gate — the acceptance predicate is the gate. - Rename predecessor → parent (aligns with the `parents` the impl already emits). - Split lineage terms: "Lineage shapes" (Head, Divergence, Reconciliation) vs "Incomplete replication" (Orphan, Unresolved). - Rationale keeps its term, bridged explicitly to the `why` field. Mutation→Commit and Receipt→Plan Version propagated across specs/requirements and the governing 0001 decision; CMP.SURF-R03 rescoped to the two-act model. Decisions and frozen example modules keep their period vocabulary. axe vrs check --profile strict: ok Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 +- ...001-compass-is-an-independent-authority.md | 8 +- context/01-data-model/requirements.md | 32 ++-- context/01-data-model/spec.md | 24 +-- .../.delta/DELTA-001-repair-has-no-command.md | 8 +- context/02-artifacts/requirements.md | 2 +- context/02-artifacts/spec.md | 20 +-- context/03-surface/requirements.md | 33 +++-- context/03-surface/spec.md | 72 +++++---- context/04-cli/requirements.md | 10 +- context/04-cli/spec.md | 9 +- context/05-integrations/requirements.md | 2 +- context/05-integrations/spec.md | 10 ++ context/06-api/requirements.md | 10 +- context/06-api/spec.md | 21 +-- context/07-evals/spec.md | 4 +- context/ontology.md | 139 +++++++++++------- context/open-questions.md | 12 +- context/requirements.md | 6 +- context/roadmap.md | 2 +- context/spec.md | 12 +- examples/README.md | 4 +- examples/two-machines/README.md | 8 +- 23 files changed, 256 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index 28a0387..186b7ee 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ Durable planning intent for coding agents. **A plan is immutable. Planning is continuous.** Compass stores plans as a chain of immutable versions. You never edit a plan — -you revise it, which appends a new version naming its predecessor and stating +you revise it, which appends a new version naming its parent and stating *why* intent changed. The plan at the tip is a disposable guess; the chain of reasons that produced it is what compounds. A plan is a TypeScript module. A step is a named declaration, a dependency is a -reference to another step, and a revision is a function of its predecessor. See +reference to another step, and a revision is a function of its parent. See [`examples/`](./examples/) for three worked plans, and [`context/`](./context/) for why it is shaped this way. @@ -89,7 +89,7 @@ validates the *structure* of a criterion and stays neutral about what it means. ## "Isn't this just git?" Its object model is close to git's: immutable snapshots naming their -predecessors, a required message per change, divergence as a legitimate state, +parents, a required message per change, divergence as a legitimate state, reconciliation with multiple parents. If you're thinking *why not a `plan.md` and good commit messages* — that gets you a lot of this. @@ -122,7 +122,7 @@ version — so a Plan is named by nothing and filed under a value it derives (decision 0017). A committed version *is* the module you wrote, stored unchanged and named by the hash of its bytes. There is no separate rendered form, so nothing can drift from what you authored, and altering a committed version changes its hash — which is -how tampering is caught. A revision imports its predecessor by that hashed name, +how tampering is caught. A revision imports its parent by that hashed name, so the lineage is a real module graph. There is no head file. That removes the cell concurrent writers would contend @@ -131,7 +131,7 @@ flight, so convergence comes from the sync layer instead. Point a file-sync mechanism with union / newer-wins / no-delete semantics at the catalog. Without one, Compass runs single-machine. -Reading a plan evaluates it — and, through its imports, its predecessors and any +Reading a plan evaluates it — and, through its imports, its parents and any plans it references. That evaluation runs against a locked-down engine with no clock, no filesystem, no network, and no way to run code the plan didn't declare; it has to be, because under replication those modules were authored on another diff --git a/context/.decisions/0001-compass-is-an-independent-authority.md b/context/.decisions/0001-compass-is-an-independent-authority.md index bab5fb2..5c8a9cf 100644 --- a/context/.decisions/0001-compass-is-an-independent-authority.md +++ b/context/.decisions/0001-compass-is-an-independent-authority.md @@ -41,9 +41,9 @@ Compass is a standalone authority. Its core owns goals, Steps, dependencies, acceptance, revisions, and accepted progress. It depends on no other tool's paths, schemas, event envelopes, or storage layouts. -Other systems compose with Compass through opaque references, mutations, -queries, and receipts. They may record operational facts referencing a -Receipt, but such facts never become Compass state. Compass exposes no +Other systems compose with Compass through opaque references, commits, +queries, and version identities. They may record operational facts referencing a +Plan Version, but such facts never become Compass state. Compass exposes no subcommand inside another tool's CLI namespace; a facade would make the namespace imply authority. @@ -52,6 +52,6 @@ namespace imply authority. - The Catalog root is configuration, not a compiled-in path. - Composition is one-directional: Compass never reads another tool to reconstruct its own state. -- Surrounding systems store opaque refs and receipts only. +- Surrounding systems store opaque refs and version identities only. - Moving Compass behind a different transport or process changes packaging, not identifiers or persisted semantics. diff --git a/context/01-data-model/requirements.md b/context/01-data-model/requirements.md index 15ef61a..9fa7415 100644 --- a/context/01-data-model/requirements.md +++ b/context/01-data-model/requirements.md @@ -10,7 +10,7 @@ ### Lineage - **CMP.DM-R01 A Plan is a lineage of versions.** Each version records its - predecessors, so the history of intent is reconstructible from the versions + parents, so the history of intent is reconstructible from the versions alone. _refines: CMP-R02._ - **CMP.DM-R02 Every version carries a Rationale.** The reason for the revision @@ -22,10 +22,10 @@ _refines: CMP-R02, CMP-R04._ - **CMP.DM-R04 Divergence is a state, not an error.** Versions sharing a - predecessor are both valid and both reported. _refines: CMP-R04._ + parent are both valid and both reported. _refines: CMP-R04._ - **CMP.DM-R05 Divergence resolves by authorship.** Reconciliation is an - ordinary version naming every predecessor it reconciles, with its own + ordinary version naming every parent it reconciles, with its own Rationale. Nothing reconciles automatically, and a reconciliation may itself diverge. _refines: CMP-R03, CMP-R04._ @@ -42,15 +42,15 @@ must be visible at the moment of retirement rather than discovered later through readiness that never advances. _refines: CMP-R01._ -- **CMP.DM-R06 An absent predecessor is not divergence.** A version whose - predecessor is unknown must be distinguished from one that disagrees. The +- **CMP.DM-R06 An absent parent is not divergence.** A version whose + parent is unknown must be distinguished from one that disagrees. The first ordinarily means state is still arriving; treating it as the second writes permanent intent to resolve a transient condition. _refines: CMP-R04, CMP-R05._ - **CMP.DM-R06a An unreadable Plan is distinguished from an incomplete one.** A Plan that cannot be evaluated because something it references is absent must - be reported as unresolved, distinctly from a version whose predecessor is + be reported as unresolved, distinctly from a version whose parent is merely missing. The two look alike and are not: an incomplete lineage still answers what the Plan says, while an unresolved Plan answers nothing at all. Reporting the second as the first invites waiting for a repair that has @@ -63,11 +63,11 @@ siblings, since neither observed the other, and where one version does precede another the lineage already says so. _refines: CMP-R03, CMP-R04, CMP-R10._ -- **CMP.DM-R07a Repeating a mutation does not repeat its effect.** A mutation +- **CMP.DM-R07a Repeating a Commit does not repeat its effect.** A Commit applied twice produces one version. This must follow from the data — an - identical mutation yields an identical version, therefore the same identity — + identical Commit yields an identical version, therefore the same identity — rather than from a token a caller supplies and could supply wrongly. It holds - only because a revision states its predecessor as part of its own content: a + only because a revision states its parent as part of its own content: a base that were read at the moment of application would have moved by the time a retry arrived, and the retry would differ from the attempt it repeats. _refines: CMP-R02, CMP-R10._ @@ -80,7 +80,7 @@ The cost is that a deliberate non-change cannot be recorded. _refines: CMP-R02, CMP-R03._ -- **CMP.DM-R07c A revision carries its predecessor forward.** A revision is +- **CMP.DM-R07c A revision carries its parent forward.** A revision is expressed against the version before it and can edit a Step, add one, or retire one. It has no way to remove one. Dropping a Step is therefore not something Compass detects and refuses but something a revision cannot express @@ -126,7 +126,7 @@ every hash in the lineage stays constant. _refines: CMP-R02, CMP-R07._ - **CMP.DM-R17 A Plan's identity is its origin.** A Plan is identified by the - content hash of its origin — the one version with no predecessor. It is + content hash of its origin — the one version with no parent. It is derived, never declared and never minted, and encodes no location. Two versions are the same Plan when they share an origin. A version whose derived Plan disagrees with where it is filed is rejected, not reinterpreted. @@ -205,13 +205,13 @@ ### Readiness - **CMP.DM-R14 Readiness is derived.** What can be worked on now follows from - the Step graph at head, accepted progress, and gates. It is part of the model, - not a projection over it. _refines: CMP-R01._ + the Step graph at head, accepted progress, and each Step's acceptance + criterion. It is part of the model, not a projection over it. _refines: CMP-R01._ - **CMP.DM-R15 Readiness explains itself.** Every answer names the unsatisfied - dependencies and gates. An answer that cannot say why is neither trustworthy - nor debuggable, and this constrains what an acceptance criterion may express. - _refines: CMP-R01._ + dependencies and unmet criteria. An answer that cannot say why is neither + trustworthy nor debuggable, and this constrains what an acceptance criterion + may express. _refines: CMP-R01._ - **CMP.DM-R16 Readiness is defined under divergence.** With more than one head member, readiness is reported per member and labelled. It never selects a side diff --git a/context/01-data-model/spec.md b/context/01-data-model/spec.md index fe86565..c7c59bf 100644 --- a/context/01-data-model/spec.md +++ b/context/01-data-model/spec.md @@ -7,7 +7,7 @@ Realizes [requirements.md](./requirements.md). Storage is specified in A Plan is a lineage of Versions plus the Progress recorded against them. It is named by a `PlanId` — the content hash of its origin, the one version with no -predecessor (decision 0017). Identity is derived from the origin, so two +parent (decision 0017). Identity is derived from the origin, so two versions are the same Plan iff they share one; the origin's own identity is the PlanId. The human handle for a Plan is its required `goal`, not the PlanId. @@ -20,14 +20,14 @@ fields of a record: | Declared | Meaning | | --- | --- | | plan | the Plan this version belongs to | -| predecessors | each version it revises — none for the first, one ordinarily, several for a reconciliation | +| parents | each version it revises — none for the first, one ordinarily, several for a reconciliation | | author | who authored the revision | | rationale | why intent changed — required | | goal | the intent being pursued | | steps | zero or more named Step declarations | | retired | whether the Plan itself is decommissioned | -A predecessor is not a name copied into the version; it is the predecessor +A parent is not a name copied into the version; it is the parent itself, referenced. That is what lets a revision be written as a function of what came before, and it is why a retry cannot drift: the base a revision was written against is part of the revision, not something read when it is applied. @@ -49,24 +49,24 @@ itself. Whether that gap should be closed is unresolved (DQ07). ## Revision -A revision is written against its predecessor and may: +A revision is written against its parent and may: - **edit** a Step — change its work, dependencies, or acceptance, - **add** a Step — a new declaration, whose name becomes its identity, - **retire** a Step — mark it decommissioned while it stays in the Plan. -There is no fourth operation. Every Step of the predecessor is carried forward +There is no fourth operation. Every Step of the parent is carried forward unless it is edited or retired, so a revision has no way to say "and this one is gone." Losing a Step silently is not caught by a check; it has no spelling. Retirement is therefore visible in the lineage: a retired Step appears in every later version, marked, rather than ceasing to appear. -A reconciliation is a revision with more than one predecessor and behaves the -same way: every Step of every predecessor is carried forward, and the version +A reconciliation is a revision with more than one parent and behaves the +same way: every Step of every parent is carried forward, and the version states only what it resolves. This is what makes reconciliation derivable against each side rather than a choice of one side whose rejected half vanishes -unrecorded. Where two predecessors disagree about the same Step, the +unrecorded. Where two parents disagree about the same Step, the reconciliation must say which intent survives; how that is stated, and what happens if it does not, is unresolved (DQ08). @@ -94,9 +94,9 @@ properties and therefore use different mechanisms; this asymmetry is deliberate. Head is the set of versions with no successor, computed by walking the lineage. -- **Divergence** — two or more versions share a predecessor. Both are valid. -- **Reconciliation** — an ordinary version naming several predecessors. -- **Orphan** — a version whose predecessor is unknown locally. +- **Divergence** — two or more versions share a parent. Both are valid. +- **Reconciliation** — an ordinary version naming several parents. +- **Orphan** — a version whose parent is unknown locally. - **Unresolved** — a Plan that cannot be evaluated, because something it references is not present locally. @@ -179,7 +179,7 @@ rather than the scope it is judged in. A Step is ready when it is not retired, its acceptance criterion is not yet satisfied, and every Step it depends on has a satisfied criterion. -Every answer carries its reasons — which dependency or gate is unsatisfied. +Every answer carries its reasons — which dependency or criterion is unsatisfied. Under divergence, readiness is computed per head member and labelled with it. Merging the graphs would produce a plan nobody wrote; picking a side would hide diff --git a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md index e712bf7..2ce7f95 100644 --- a/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md +++ b/context/02-artifacts/.delta/DELTA-001-repair-has-no-command.md @@ -10,7 +10,7 @@ Damage can be detected but not repaired through a dedicated operation. [requirements.md](../requirements.md) CMP.FS-R11 requires that recovery from damage proceed by authoring new content that records the damage and continues -from the last intact predecessor. [04-cli/spec.md](../../04-cli/spec.md) states +from the last intact parent. [04-cli/spec.md](../../04-cli/spec.md) states that verification and repair are separate commands, and gives the reason: verification is safe to run anywhere, while repair authors permanent content that replication makes irreversible, so collapsing them would put the @@ -22,11 +22,11 @@ irreversible operation one keystroke from the safe one. frontier that will not evaluate. `compass repair` now exists as a *distinct* command: it re-runs verification and **refuses when nothing is wrong** (so the irreversible operation is never one keystroke from the safe one), identifies the -last intact predecessor, and lists which versions are unverifiable. +last intact parent, and lists which versions are unverifiable. What it does not yet do is author the damage-recording version itself: it scaffolds the `prior.revise({...})` continuation from the last intact -predecessor and directs the operator to `compass commit` it. The separation the +parent and directs the operator to `compass commit` it. The separation the spec relies on is enforced (verify is read-only; repair is its own command that refuses on a clean catalog); the authoring is guided rather than performed. @@ -37,6 +37,6 @@ update implementation ## Resolution Signal A distinct command authors a damage-recording version: it identifies the last -intact predecessor, requires a Rationale, records which versions are +intact parent, requires a Rationale, records which versions are unverifiable and why, and refuses to run when verification reports nothing wrong. Verification remains read-only. diff --git a/context/02-artifacts/requirements.md b/context/02-artifacts/requirements.md index 5ee5310..e11a350 100644 --- a/context/02-artifacts/requirements.md +++ b/context/02-artifacts/requirements.md @@ -89,7 +89,7 @@ - **CMP.FS-R11 Repair never rewrites history.** Recovery from damage proceeds by authoring new content that records the damage and continues from the last - intact predecessor. Editing or deleting a damaged version cascades through + intact parent. Editing or deleting a damaged version cascades through every descendant, and deletion returns on the next sync. _refines: CMP-R07, CMP-R02._ diff --git a/context/02-artifacts/spec.md b/context/02-artifacts/spec.md index 2338da5..d8ddc5f 100644 --- a/context/02-artifacts/spec.md +++ b/context/02-artifacts/spec.md @@ -18,10 +18,10 @@ a version whose content does not match its own name — a misfiled version is ne reinterpreted into the Plan it was filed under. `seq` is a reading aid, not a key. Divergent versions may share one, and after a -reconciliation of unequal lineages it follows the longest predecessor. Nothing +reconciliation of unequal lineages it follows the longest parent. Nothing resolves on `seq`; the hash is the identity. -A version file is a module. A revision refers to its predecessor by referencing +A version file is a module. A revision refers to its parent by referencing that file, so a version's name appears inside its successors — which is why the name is fixed at commit and never recomputed. @@ -41,7 +41,7 @@ moves when the bytes move is exactly what makes an alteration of committed source visible. A normalization that let identity survive reformatting would also let it survive tampering. -Because each version references its predecessors by name, and each name is a +Because each version references its parents by name, and each name is a hash of content, altering any version changes its identity and breaks every descendant's reference — so damage is detectable by walking, without a separate manifest. @@ -70,7 +70,7 @@ whose content contradicts their name are rejected with an error naming both. Admission looks at bytes and nothing else. It does not evaluate the module, and in particular does not require that what the module references be present: replication delivers files in no useful order, so a version arrives before its -predecessor about as often as after. An admission rule that ran the module would +parent about as often as after. An admission rule that ran the module would make delivery order decide what became state, and would reject a perfectly good version for a gap that closes on the next sync. @@ -90,12 +90,12 @@ Head is computed by walking. Nothing on disk records it, so concurrent writers have nothing to contend on. ```text -versions/003-a1b2….ts predecessor = 002-… -versions/003-c3d4….ts predecessor = 002-… ← divergence: shared predecessor -versions/004-e5f6….ts predecessors = [a1b2…, c3d4…] ← reconciliation +versions/003-a1b2….ts parent = 002-… +versions/003-c3d4….ts parent = 002-… ← divergence: shared parent +versions/004-e5f6….ts parents = [a1b2…, c3d4…] ← reconciliation ``` -An orphan is a version referencing a predecessor no local file provides. It is +An orphan is a version referencing a parent no local file provides. It is reported as incomplete state, not as divergence, and never offered reconciliation as its repair. @@ -122,14 +122,14 @@ edits or deletes a damaged version: of repairing it. Instead a new version records the damage, states what is known of the lost -intent, and continues from the last intact predecessor. The surviving record of +intent, and continues from the last intact parent. The surviving record of reasons is preserved, which is the property worth protecting; the damaged bytes remain on disk and are excluded from interpretation. Repair is more urgent than a broken link would suggest. A version that cannot be resolved cannot be evaluated, and every later version references it, so damage mid-lineage does not leave a Plan partially readable — it leaves it unreadable -to the tip. The repair version, continuing from the last intact predecessor, is +to the tip. The repair version, continuing from the last intact parent, is what restores a readable frontier. The same mechanism is the only response to content that should not have been diff --git a/context/03-surface/requirements.md b/context/03-surface/requirements.md index d18dcf0..bb35f10 100644 --- a/context/03-surface/requirements.md +++ b/context/03-surface/requirements.md @@ -14,28 +14,31 @@ would be a second authority. _refines: CMP-R01, CMP-R09._ - **CMP.SURF-R02 Transport-neutral.** The surface is defined in terms of - references, mutations, queries, and results, and carries no assumption about + references, commits, queries, and results, and carries no assumption about process, protocol, or invocation. Changing how it is reached must not change what it means. _refines: CMP-R08._ -- **CMP.SURF-R03 A mutation is one accepted transition.** Each mutation names - one domain transition — creation, revision, acceptance change, progress, - retirement, reconciliation — and is applied whole or not at all. +- **CMP.SURF-R03 A write is a Commit or a Progress append.** The surface has + exactly two write acts. A Commit names one version-producing transition — + creation, revision, or reconciliation — and is applied whole or not at all. + Recording progress appends one Progress Event and produces no version. + Conflating them would let an inert record masquerade as a change of intent. _refines: CMP-R02._ -- **CMP.SURF-R04 Success yields a stable Receipt.** An accepted mutation returns - a result naming the affected references and the resulting version identity, - which remains valid for later reference. _refines: CMP-R09._ +- **CMP.SURF-R04 A Commit yields the stored Version.** An accepted Commit + returns the Plan Version it stored — its identity and lineage coordinates, + valid for later reference. Content addressing means the version's identity is + the result; there is no separate token. _refines: CMP-R09._ -- **CMP.SURF-R05 Failure yields nothing.** A rejected mutation produces no - receipt, records no state, permits no external record claiming success, and - leaves what the caller authored untouched. Nothing is written back into - authored content, so a refusal costs exactly the work of resubmitting. +- **CMP.SURF-R05 Failure yields nothing.** A rejected Commit produces no version, + records no state, permits no external record claiming success, and leaves what + the caller authored untouched. Nothing is written back into authored content, + so a refusal costs exactly the work of resubmitting. _refines: CMP-R09, CMP-R02._ -- **CMP.SURF-R06 Repetition is not duplication.** Submitting the same mutation - more than once records it once. Two submissions are the same when they carry - the same authored content, which yields the same identity and therefore one +- **CMP.SURF-R06 Repetition is not duplication.** Committing the same content + more than once records it once. Two Commits are the same when they carry the + same authored content, which yields the same identity and therefore one version — a property of the data rather than a protocol both sides must implement correctly. _refines: CMP-R02, CMP-R10._ @@ -45,7 +48,7 @@ _refines: CMP-R05._ - **CMP.SURF-R08 External records follow, never lead.** An outside system may - record a fact referencing a Receipt only after the mutation is accepted. Its + record a fact referencing a Plan Version only after the Commit is accepted. Its failure never rolls back, blocks, or alters the result. _refines: CMP-R09._ diff --git a/context/03-surface/spec.md b/context/03-surface/spec.md index aa6f42f..f16251a 100644 --- a/context/03-surface/spec.md +++ b/context/03-surface/spec.md @@ -5,14 +5,18 @@ Realizes [requirements.md](./requirements.md). ## Shape ```text -read(PlanId) -> PlanView | NotFound | Unresolved | Stopped -ready(PlanId) -> Readiness | NotFound | Unresolved | Stopped -mutate(Mutation) -> Receipt | Rejected +read(PlanId) -> PlanView | NotFound | Unresolved | Stopped +ready(PlanId) -> Readiness | NotFound | Unresolved | Stopped +commit(Module) -> Version | Rejected +record(Progress) -> Recorded | Rejected ``` -Three operations, transport-neutral. The surface holds no state; it is the +Four operations, transport-neutral. The surface holds no state; it is the sanctioned path to the state described in -[02-artifacts](../02-artifacts/spec.md). +[02-artifacts](../02-artifacts/spec.md). It has exactly two write acts, and they +are not the same kind of thing: a Commit stores a Plan Version, and recording +Progress appends an inert event. The first produces new intent; the second never +does. A read is an evaluation, so it has failure modes a lookup does not. `Unresolved` says the Plan is here but something it references is not, and the @@ -21,44 +25,54 @@ memory, and the repair is not to wait — a Plan that will not terminate will no terminate later either. Collapsing either into `NotFound` would send a caller looking for a Plan that is sitting in front of it. -## Mutations +## Commit and progress -A `Mutation` names one domain transition: create a Plan, revise intent, change -acceptance, record progress, retire, or reconcile a divergence. Each is applied -whole or not at all. +The two write acts are distinct, not two spellings of one umbrella. -The command vocabulary — how a mutation is spelled by a caller — belongs to the +A **Commit** stores a Plan Version. It names one version-producing transition — +bring a Plan into being, revise its intent, or reconcile a divergence — and it +is applied whole or not at all. Changing a Step's acceptance and retiring a Step +are revisions, not separate acts. + +Recording **Progress** appends a Progress Event against a Step: start, update, +handoff, completion, evidence. It produces no version, alters no structural +intent, and is inert data — read without being evaluated. It is a write only in +that it goes through the one sanctioned path; it is nothing like a Commit +otherwise. + +The command vocabulary — how each act is spelled by a caller — belongs to the consumer, not to this contract. [04-cli](../04-cli/spec.md) defines one spelling. -## Receipts +## What a Commit returns -An accepted mutation returns a `Receipt`: the affected references and the -resulting version identity. A receipt remains valid for later reference, which -is what lets an external system record a fact about a mutation without holding -Compass state. +An accepted Commit returns the **Plan Version** it stored: its identity and +lineage coordinates. Content addressing means there is no separate token to +return — the version's hash *is* the durable reference, valid forever, which is +what lets an external system record a fact about a Commit without holding Compass +state. -A rejected mutation returns `Rejected` and nothing else — no receipt, no partial +A rejected Commit returns `Rejected` and nothing else — no version, no partial write, no basis for an external success record, and no change to what the caller authored. ## Repetition -Submitting the same mutation twice records it once. +Committing the same content twice records it once. -Two submissions are the same when they carry the same authored content. That -content names its own predecessor, so a retry is not re-evaluated against a base -that moved while it was away: it produces the same bytes, therefore the same -identity, therefore the version that already landed. Nothing is written and the -caller is told what is already there. +Two Commits are the same when they carry the same authored content. That content +names its own parent, so a retry is not re-evaluated against a base that +moved while it was away: it produces the same bytes, therefore the same identity, +therefore the version that already landed. Nothing is written and the caller is +told what is already there. -A retry that was *reworded* is a different submission by construction, since the +A retry that was *reworded* is a different Commit by construction, since the content differs. It is caught instead by refusing a revision that alters no Step and no goal — which is a different rule for a different case, and the reason both exist. No caller-supplied key is involved. A key is a value a caller chooses, and can -therefore be reused for a different mutation, regenerated per attempt, or +therefore be reused for a different Commit, regenerated per attempt, or forgotten, each quietly. ## Reads and convergence @@ -71,10 +85,10 @@ settled. ## Composition -An external system may record a fact referencing a Receipt after the mutation is -accepted. That record never becomes Compass state, and its failure is reported +An external system may record a fact referencing a Plan Version after the Commit +is accepted. That record never becomes Compass state, and its failure is reported separately without touching the result. -Integrations exchange references, mutations, queries, and receipts. They do not -share mutable files and do not write Compass state directly — which is what -keeps CMP.SURF-R01 true in the presence of other tools. +Integrations exchange references, commits, queries, and version identities. They +do not share mutable files and do not write Compass state directly — which is +what keeps CMP.SURF-R01 true in the presence of other tools. diff --git a/context/04-cli/requirements.md b/context/04-cli/requirements.md index fdecc31..6a60e2b 100644 --- a/context/04-cli/requirements.md +++ b/context/04-cli/requirements.md @@ -29,9 +29,9 @@ would teach operators to treat it as breakage. _refines: CMP-R04._ - **CMP.CLI-R05 Answers carry their reasons.** Readiness renders the - unsatisfied dependencies and gates alongside its answer, and verification - renders what is broken and where. An unexplained answer is not usable. - _refines: CMP-R01, CMP-R07._ + unsatisfied dependencies and unmet criteria alongside its answer, and + verification renders what is broken and where. An unexplained answer is not + usable. _refines: CMP-R01, CMP-R07._ - **CMP.CLI-R06 Inspection is separated from authorship.** Commands that only read are distinguishable from commands that write permanent content, and the @@ -52,7 +52,7 @@ can be derived from state the CLI can already reach, the CLI derives it rather than accepting it. A caller naming a Plan must not also be asked for its sequence or its file layout. Every such argument is a chance to supply the one - wrong answer, and it is a chance the tool created. A revision's predecessor is + wrong answer, and it is a chance the tool created. A revision's parent is neither derived nor asked for: it is written in the revision itself, which is what makes a retry repeat rather than drift. _refines: CMP-R10._ @@ -77,7 +77,7 @@ did not revise. The two must not report alike. _refines: CMP-R02._ - **CMP.CLI-R13 What a commit would do is inspectable before it happens.** The - structural change authored content makes against its predecessors is + structural change authored content makes against its parents is reportable without committing, since under no-delete replication a commit cannot be walked back. Establishing it means evaluating the content, so this is a read that runs a program and is subject to the same bound as any other. diff --git a/context/04-cli/spec.md b/context/04-cli/spec.md index c4c238c..b0fa021 100644 --- a/context/04-cli/spec.md +++ b/context/04-cli/spec.md @@ -40,7 +40,7 @@ Authoring *new* content that revises nothing is a different case and is refused with a different message. One says "this is already committed"; the other says "this changes nothing." Rendering them alike would hide which happened. -Committing an origin — a module with no predecessor — brings a Plan into being, +Committing an origin — a module with no parent — brings a Plan into being, and its identity is derived then: the PlanId is the hash of that origin (decision 0017). The operator names nothing. Afterwards a Plan is addressed by its `goal` where a person is reading and by its PlanId where exactness is @@ -109,6 +109,7 @@ output, so it is a first-class command rather than a verbose mode. ## Errors -A rejected mutation reports what was rejected and why, and states plainly that -nothing was recorded. Ambiguity about whether a failed write partially applied -is the worst outcome an append-only store can present. +A rejected write — a Commit or a progress append — reports what was rejected and +why, and states plainly that nothing was recorded. Ambiguity about whether a +failed write partially applied is the worst outcome an append-only store can +present. diff --git a/context/05-integrations/requirements.md b/context/05-integrations/requirements.md index 662be8b..56e3807 100644 --- a/context/05-integrations/requirements.md +++ b/context/05-integrations/requirements.md @@ -83,7 +83,7 @@ - **CMP.INT-R10 An undelivered reference makes a Plan unreadable, not merely incomplete.** Replication that has not yet delivered something a Plan references leaves that Plan unresolved: it answers nothing at all, rather than - answering with a short lineage. This is more severe than a missing predecessor + answering with a short lineage. This is more severe than a missing parent and must be reported as its own condition, because an operator who reads it as an ordinary gap will wait for a Plan to fill in when what is actually missing is the thing without which nothing can be read. _refines: CMP-R05, CMP-R07._ diff --git a/context/05-integrations/spec.md b/context/05-integrations/spec.md index aee6e12..4df1721 100644 --- a/context/05-integrations/spec.md +++ b/context/05-integrations/spec.md @@ -121,3 +121,13 @@ index Compass keeps to avoid re-evaluating what it has already evaluated is machine-local and is not declared to the sync mechanism: it is computable from what does replicate, so shipping it would buy nothing and would create a value two machines could disagree about. + +## External records follow, never lead + +A surrounding system may record an operational fact of its own — a deployment, +a notification, a dashboard row — after a Commit is accepted, and may reference +the resulting Plan Version, a PlanId, or a Step by its name. Such a record is +that system's concept, not Compass's: it never becomes Compass state, and its +absence or failure never changes a Compass result. Compass defines no term for +it, because it belongs to whoever emits it. This is the integration side of +CMP.SURF-R08 — the guarantee that composition is one-directional. diff --git a/context/06-api/requirements.md b/context/06-api/requirements.md index 338db33..65120d3 100644 --- a/context/06-api/requirements.md +++ b/context/06-api/requirements.md @@ -2,7 +2,7 @@ > **Role.** The library a Plan imports — the surface through which intent is > written as code. It is what makes a Step a named declaration, a dependency a -> variable, and a revision a function of its predecessor. It realizes the data +> variable, and a revision a function of its parent. It realizes the data > model of [01-data-model](../01-data-model/requirements.md) as a thing an > author writes, and its output is evaluated per > [decision 0014](../.decisions/0014-a-version-is-a-module-and-peer-code-is-executed.md). @@ -28,14 +28,14 @@ exist — caught where it is written — rather than a dangling edge discovered later. _refines: CMP-R10._ -- **CMP.API-R03 A revision is a function of its predecessor.** Producing a +- **CMP.API-R03 A revision is a function of its parent.** Producing a version from a prior one is an operation *on* that version. It takes edits, additions, and retirements, and offers nothing that removes a Step: every Step - of the predecessor is carried forward by the operation itself, so a Step + of the parent is carried forward by the operation itself, so a Step cannot be dropped by omission. _refines: CMP-R02._ - **CMP.API-R04 An edit names the Step it changes.** Editing a carried-forward - Step refers to that Step through the predecessor, so the edit cannot silently + Step refers to that Step through the parent, so the edit cannot silently create a new Step or target one that is not there. What an edit may change is the work, the acceptance, and the dependencies; it cannot change identity. _refines: CMP-R02, CMP-R10._ @@ -43,7 +43,7 @@ - **CMP.API-R05 A cross-plan reference is an import.** Referring to another Plan's version is importing it. The import is what makes the reference checkable rather than spelled, and it is why the other Plan must be present to - evaluate this one. The imported version is a reference, never a predecessor of + evaluate this one. The imported version is a reference, never a parent of the importing version. A Step *depending on* a Step in another Plan is a larger feature with unsettled cross-Plan readiness semantics and is deferred (DQ11); this requirement covers the reference, not the dependency edge. diff --git a/context/06-api/spec.md b/context/06-api/spec.md index caff915..74ab7f7 100644 --- a/context/06-api/spec.md +++ b/context/06-api/spec.md @@ -7,7 +7,7 @@ and [0014](../.decisions/0014-a-version-is-a-module-and-peer-code-is-executed.md The exact spelling below is illustrative. What is normative is the shape: a Step is a named binding, a dependency is a reference to a binding, a revision is an -operation on a predecessor value, and everything is pure construction. +operation on a parent value, and everything is pure construction. ## The module a plan imports @@ -57,8 +57,8 @@ handle, shown wherever a Plan is listed or referenced in place of the hash. ## A revision -A revision imports its predecessor and is an operation on it. It carries every -Step of the predecessor forward, and offers only edits, additions, and +A revision imports its parent and is an operation on it. It carries every +Step of the parent forward, and offers only edits, additions, and retirements — there is no parameter that removes a Step, so a Step cannot go missing while a plan is rewritten. @@ -83,7 +83,7 @@ export default prior.revise({ }) ``` -`prior.steps.fix` refers to the carried-forward Step through the predecessor, so +`prior.steps.fix` refers to the carried-forward Step through the parent, so an edit cannot target a Step that is not there or invent a new one. `.with(...)` changes work, acceptance, or dependencies; it cannot change identity, because identity is the binding and the binding is unchanged. An *added* Step is declared @@ -97,8 +97,8 @@ case. ## A reconciliation -A reconciliation is a revision with more than one predecessor. Every Step of -every predecessor is carried forward, so nothing is lost by choosing a side; the +A reconciliation is a revision with more than one parent. Every Step of +every parent is carried forward, so nothing is lost by choosing a side; the only thing the version states is what changed. ```ts @@ -138,15 +138,16 @@ why the other Plan must be present to evaluate this one. A machine that has not received that Plan (the one whose goal is "cut the release branch") cannot read this one at all, and reports it as Unresolved rather than showing an incomplete graph. The imported version is a reference, -not a predecessor: it does not become a parent of the importing version, and its -absence-of-admission there is an error, not an uncommitted-predecessor. +not a parent: it does not become a parent of the importing version, and its +absence-of-admission there is an error, not an uncommitted-parent. A Step **depending on** a Step in another Plan — `dependsOn: [release.steps.branchCut]` — is a different and larger thing, and is **not yet supported**. A dependency is validated against the importing version's own Steps, and readiness folds within one Plan; a dependency edge that crosses Plans -raises questions neither answers — whether the other Plan's Step gates this one, -how readiness folds across Plans, what an out-of-Plan retirement does here. Those +raises questions neither answers — whether the other Plan's Step blocks this one +until accepted, how readiness folds across Plans, what an out-of-Plan retirement +does here. Those are open (DQ11). A cross-plan *reference* resolves today; a cross-plan *dependency edge* does not. diff --git a/context/07-evals/spec.md b/context/07-evals/spec.md index 53ad6db..16f64e4 100644 --- a/context/07-evals/spec.md +++ b/context/07-evals/spec.md @@ -68,7 +68,7 @@ with no network and no sync mechanism: catalog A ──copy──▶ catalog B both directions, never removing ``` -An orphan is produced by copying a subset — a version whose predecessor has not +An orphan is produced by copying a subset — a version whose parent has not arrived yet — which is the real condition rather than a simulation of it. The same subset produces an unresolved Plan, and a scenario distinguishes the two by what the read returns rather than by how the files were arranged. @@ -104,7 +104,7 @@ asserted: | a hypothesis that dies | the Rationale chain is the artifact — CMP-R03 | | two machines, one plan | divergence survives replication — CMP-R04, CMP.DM-R04 | | staleness, not disagreement | an orphan is distinguished from divergence — CMP.DM-R06 | -| the crash-retry | a repeated mutation records once — CMP.DM-R07a, CMP.CLI-R12 | +| the crash-retry | a repeated Commit records once — CMP.DM-R07a, CMP.CLI-R12 | | the same work, said better | identity survives rewording — CMP.DM-R08 | | a step nobody can drop | dropping is unrepresentable — CMP.DM-R07c, CMP.EVAL-R09 | | a step is retired | dependents are stranded visibly — CMP.DM-R05b | diff --git a/context/ontology.md b/context/ontology.md index 9099246..e433d00 100644 --- a/context/ontology.md +++ b/context/ontology.md @@ -17,7 +17,7 @@ is revised, which produces a new Plan Version. _Avoid_: ticket, issue, epic, backlog, board **PlanId**: -A Plan's identity: the content hash of its origin — the single predecessor-less +A Plan's identity: the content hash of its origin — the single parent-less version. It is derived, never declared and never minted, and encodes no filesystem, database, transport, or host location. Two versions are the same Plan when they share an origin; the origin version's own identity and the PlanId @@ -26,49 +26,72 @@ are the same hash. It is machine-facing; the human handle for a Plan is its other Plan's version rather than spelling this. _Avoid_: plan path, catalog path, file name, plan name, declared id, PlanRef, reference +**Goal**: +The one outcome a Plan pursues, stated in every version's `goal` field and +required on each. It is the Plan's human handle: where a person reads or +references a Plan, Compass shows the goal, and reserves the PlanId for where +exactness is needed. A version that changes neither a Step nor the goal is +refused, because it would assert a structural change it did not make. There is +no separate title or description; the goal is the whole human-facing name. +_Avoid_: title, name, description, summary + **Plan Version**: An immutable snapshot of a Plan's structural intent, authored as a module and stored exactly as authored. It carries a Rationale, its author, and imports each -predecessor — none for the first version, one ordinarily, several when -reconciling a Divergence. Versions are created for structural change to intent, -never for operational facts, and a version that changes neither a Step nor the -goal is refused. +parent — none for the first version, one ordinarily, several when reconciling a +Divergence. Versions are created for structural change to intent, never for +operational facts, and a version that changes neither a Step nor the goal is +refused. Identity is the hash of the version's source bytes, with nothing excluded and nothing normalized, so the name always determines the content and any alteration of a committed version is visible. Two versions with identical source are one -version: repeating a mutation therefore cannot repeat its effect, and no +version: repeating a Commit therefore cannot repeat its effect, and no caller-supplied token is involved. _Avoid_: revision row, draft, resourceVersion, logical clock, rendering **Revision**: -The act that produces a Plan Version from its predecessor, expressed as a -function of that predecessor. It may edit a Step, add one, or retire one. It has -no way to remove one: every Step of the predecessor is carried forward, so -dropping a Step is not something Compass refuses but something a revision cannot -say. +The act that produces a Plan Version from its parent, expressed as a function of +that parent. It may edit a Step, add one, or retire one. It has no way to remove +one: every Step of the parent is carried forward, so dropping a Step is not +something Compass refuses but something a revision cannot say. _Avoid_: patch, diff, regeneration, overwrite **Rationale**: -The required statement on every Plan Version explaining why intent changed. It -is the durable planning record: the artifact is the plan, the value is the -Rationale chain. It is close kin to a commit message, and differs in one -respect that matters — it is attached to a document whose Steps have identity, -so a reason can be tied to a unit of work rather than to a range of bytes. +The required statement on every Plan Version explaining why intent changed, +authored as the version's `why` field. It is the durable planning record: the +artifact is the plan, the value is the Rationale chain. It is close kin to a +commit message, and differs in one respect that matters — it is attached to a +document whose Steps have identity, so a reason can be tied to a unit of work +rather than to a range of bytes. _Avoid_: changelog entry, status note **Step**: -A stable unit of intended work within a Plan, carrying dependencies, acceptance -criteria, and lifecycle. **Its identity is the name it is declared under**, -qualified by its Plan: authored rather than minted, and independent of the -Step's content, so it survives a rewording of the same intended work. The name -is not opaque and not a separate handle — depending on a Step *names the +A stable unit of intended work within a Plan, carrying dependencies, an +Acceptance criterion, and lifecycle. **Its identity is the name it is declared +under**, qualified by its Plan: authored rather than minted, and independent of +the Step's content, so it survives a rewording of the same intended work. The +name is not opaque and not a separate handle — depending on a Step *names the declaration* (a language reference), so there is no identifier to invent or mistype. A name is never reused after the Step is retired, and a Step declared without a name has no identity and is refused. _Avoid_: task row, checklist item, ephemeral list index, StepRef, minted id, opaque token -## Lineage and its states +## Committing + +**Commit**: +The act that stores authored intent as a Plan Version, and the only way a Plan +changes — there is no second writer. A Commit reads a module, evaluates it, and +stores it exactly as authored; it is an origin-creation, a Revision, or a +Reconciliation according to how many parents the module names. It is idempotent +by content: committing bytes that already landed repeats no effect, because +identical source has identical identity, and a Commit that would change neither +a Step nor the goal is refused. A rejected Commit writes nothing. Recording +Progress against a Step is not a Commit — it appends a Progress Event and +produces no version. +_Avoid_: mutation, save, apply, publish, push, plan surface, receipt + +## Lineage shapes **Head**: The frontier of a Plan: the set of Plan Versions with no successor, derived by @@ -79,9 +102,11 @@ stored. _Avoid_: current pointer, HEAD file, latest symlink **Divergence**: -Two or more Plan Versions sharing the same predecessor — the observable result -of concurrent revision on different machines. Divergence is a legitimate state, -not an error: both versions survive replication and both are visible. +Two or more Plan Versions sharing the same parent — the observable result of +concurrent revision on different machines. It is git-style forking of one +lineage, not a rewrite collision: both versions are real and both survive. +Divergence is a legitimate state, not an error: both versions survive +replication and both are visible. A Divergence is **open** while its sides have no common descendant, and **settled** once a Reconciliation descends from all of them. The distinction is @@ -90,20 +115,21 @@ lineage and can never be removed, so a tool that does not distinguish the two reports every historical disagreement as an outstanding problem forever, and operators learn to ignore the report. Only an open Divergence asks anything of anyone. -_Avoid_: conflict, collision, fork +_Avoid_: conflict, collision, fork, divergent change **Reconciliation**: -A Plan Version naming more than one predecessor, resolving a Divergence by -stating the reconciled intent and why. It is an ordinary Plan Version in every -other respect, and is itself capable of diverging. +A Plan Version naming more than one parent, resolving a Divergence by stating +the reconciled intent and why. It is an ordinary Plan Version in every other +respect, and is itself capable of diverging. _Avoid_: rebase, conflict resolution, merge commit, fixup +## Incomplete replication + **Orphan**: -A Plan Version whose predecessor is not present locally. Distinct from -Divergence, which it superficially resembles: divergent versions share a -predecessor, an orphan is missing one. An orphan ordinarily means replication is -incomplete rather than that intent disagreed, and it is repaired by waiting, not -by reconciling. +A Plan Version whose parent is not present locally. Distinct from Divergence, +which it superficially resembles: divergent versions share a parent, an orphan +is missing one. An orphan ordinarily means replication is incomplete rather than +that intent disagreed, and it is repaired by waiting, not by reconciling. _Avoid_: fork, broken chain, corruption **Unresolved**: @@ -162,29 +188,28 @@ create a Plan Version. Unlike a version, a Progress Event is inert data: it is read without being evaluated, and nothing in the progress layer executes. _Avoid_: status field, state column, mutable progress +**Evidence**: +A typed fact a Progress Event records for an Acceptance criterion to read — a +test result, a measurement, a waiver — carrying whichever attributes its own +constructor names. Its vocabulary is supplied by whoever writes the Plan and is +defined nowhere in Compass, so a Plan for writing or research records evidence +on the same terms as one for software. A predicate binds the fields Compass +itself records, never attributes the payload merely claims, so a piece of +evidence cannot assert its own author. +_Avoid_: proof, result, claim-as-fact, log line + +**Acceptance**: +A Step's criterion for being done: a predicate over recorded Evidence, authored +as part of the Step. It answers whether the Step is complete from what has +actually been observed, without asking a judge. Compass fixes the structure of a +criterion — combinators over atoms — and never its vocabulary. Because it is the +only thing that makes a Step done, it is also what gates the Steps that depend on +it: Compass has no separate gate concept, the acceptance predicate *is* the gate. +_Avoid_: gate, check, approval, sign-off, definition-of-done + **Readiness**: The Plan-derived answer to what work is available now, computed from the Step -graph at Head, accepted progress, and gates, together with an explanation of -which dependencies and gates are unsatisfied. An answer without its explanation -is not Readiness. +graph at Head, accepted progress, and each Step's Acceptance, together with an +explanation of which dependencies or unmet criteria stand in the way. An answer +without its explanation is not Readiness. _Avoid_: queue, backlog, todo list, next action - -## Change surface and composition - -**Plan Surface**: -The transport-neutral boundary for Compass queries and mutations, and the only -sanctioned way to change a Plan. It applies a mutation and returns a stable -Receipt. A repeated mutation is the same mutation when it carries the same -authored source, which yields the same identity and therefore one version. -_Avoid_: port, API, event emitter, shared-files adapter - -**Receipt**: -The stable result of an accepted mutation, bound to its affected references and -resulting Plan Version. -_Avoid_: log acknowledgement, observation id - -**Observation**: -An operational fact emitted by a surrounding system after a Compass mutation -succeeds. It may reference a Receipt, a PlanId, or a Step by its name, but never -becomes Compass state. -_Avoid_: progress authority, completion record diff --git a/context/open-questions.md b/context/open-questions.md index 56c48f2..5af5559 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -4,7 +4,7 @@ [decision 0007](./.decisions/0007-identity-is-derived-never-asserted.md) and [decision 0014](./.decisions/0014-a-version-is-a-module-and-peer-code-is-executed.md). Identity is a hash of a version's source with nothing excluded, and a revision -states its own predecessor, so re-submitting the same content yields the same +states its own parent, so re-submitting the same content yields the same identity and therefore one version. A retry whose rationale was reworded is closed separately, by refusing a revision that changes no Step and no goal. No caller-supplied key exists, because an asserted value can be supplied wrongly @@ -38,7 +38,7 @@ it. **DQ04 — Resolved.** See [decision 0017](./.decisions/0017-a-plans-identity-is-its-origin.md). A Plan's -identity is the content hash of its origin (predecessor-less) version — derived, +identity is the content hash of its origin (parent-less) version — derived, never declared or minted, encoding no location. Two versions are the same Plan when they share an origin, and the origin's own identity is the PlanId. Human readability is carried by the required `goal`, not by the identity, so the @@ -75,7 +75,7 @@ any other and is documented as such. Progress records are unaffected — Compass writes those, so their actor is observed. **DQ08 — Resolved.** A reconciliation carries forward every Step of every -predecessor, so nothing can be lost by choosing a side. Where two predecessors +parent, so nothing can be lost by choosing a side. Where two parents define the same Step with *different* content, the reconciliation must state the surviving intent with an explicit `edit` for that Step, and is **refused otherwise** — naming the Step and both differing sides. A Step only one side @@ -116,18 +116,18 @@ checkable. A cross-plan *dependency edge* — a Step whose `dependsOn` names a S in another Plan — does not work: a dependency is validated against the importing version's own Steps, and readiness folds within one Plan. Making the edge real raises questions neither mechanism answers: does the other Plan's Step being -accepted gate this one; how does readiness fold across Plans; what does an +accepted block this one; how does readiness fold across Plans; what does an out-of-Plan retirement do to a dependent here; and what happens when the other Plan diverges. Until those are settled the reference is supported and the edge is not. **DQ12 — Does authored content carry variable references, or only the root?** CMP.FS-R05 promises machine-agnostic paths via variable references inside -authored content. In practice a version references its predecessors by *relative* +authored content. In practice a version references its parents by *relative* import and the catalog *root* is environment-resolved, which achieves machine-agnosticism without any variable expansion in the module resolver. Either the requirement is satisfied by that weaker mechanism and should say so, or variable-in-content is a real feature still to build. The relative-import form is -also what makes the flat per-Plan `versions/` layout load-bearing (a predecessor +also what makes the flat per-Plan `versions/` layout load-bearing (a parent is named by relative path), so this interacts with how a catalog may be reorganized. diff --git a/context/requirements.md b/context/requirements.md index 1484a84..ddb18f8 100644 --- a/context/requirements.md +++ b/context/requirements.md @@ -10,7 +10,7 @@ for one layer: | --- | --- | | [01-data-model](./01-data-model/requirements.md) | what a Plan is, independent of how it is stored | | [02-artifacts](./02-artifacts/requirements.md) | one realization of that model as stored modules, and the derived index over them | -| [03-surface](./03-surface/requirements.md) | the logical query and mutation surface | +| [03-surface](./03-surface/requirements.md) | the logical query and write surface | | [04-cli](./04-cli/requirements.md) | the operator surface over that port | | [05-integrations](./05-integrations/requirements.md) | contracts Compass consumes rather than defines | | [06-api](./06-api/requirements.md) | the library through which intent is written as code | @@ -94,8 +94,8 @@ for one layer: another tool's paths, storage layouts, event envelopes, or private schemas. - **CMP-R09 Composition is by reference.** Integrations must exchange stable - references, mutations, queries, and receipts. They must not share mutable - files or mutate Compass state directly. A reference is stable — it survives + references, commits, queries, and version identities. They must not share + mutable files or write Compass state directly. A reference is stable — it survives revision and names one thing forever — but it is not required to be meaningless: a Step is referenced by the name it was declared under, which a reader can read. diff --git a/context/roadmap.md b/context/roadmap.md index 2ecb8a3..160b398 100644 --- a/context/roadmap.md +++ b/context/roadmap.md @@ -39,7 +39,7 @@ does not follow from the index: the index makes re-reading cheap and does nothing about how many files exist or how many are scanned to discover them. A summary version is harder here than under an inert format, because a later -version references its predecessor and evaluating it evaluates the chain. A +version references its parent and evaluating it evaluates the chain. A summary that is not referenced saves nothing, and one that is referenced changes what the lineage says. diff --git a/context/spec.md b/context/spec.md index dcb9109..3a282bc 100644 --- a/context/spec.md +++ b/context/spec.md @@ -74,8 +74,8 @@ Every change passes through the Plan Surface; nothing writes Compass state around it, because a second writer would be a second authority. Other systems compose by reference. They may record operational facts citing a -Receipt, but such a fact never becomes Compass state, and its absence or failure -never changes a Compass result. +Plan Version, but such a fact never becomes Compass state, and its absence or +failure never changes a Compass result. Compass owns two regimes, deliberately distinct: **intent**, which is immutable and versioned, and **progress**, which is append-only and operational. Neither @@ -106,7 +106,7 @@ records what that alternative looks like. A Plan created, revised, diverged, and reconciled. The exact spelling of the authoring API is illustrative; that intent is a module, that a Step is a named -declaration, and that a revision is a function of its predecessor are not. +declaration, and that a revision is a function of its parent are not. **1 — created.** Each Step is a declaration, and its name is its identity. @@ -138,7 +138,7 @@ reference. There is no identifier to mistype and none to invent: a dependency that does not resolve is not a dangling edge discovered later, it is a name that does not exist, and it fails where it is written. -**2 — revised.** The revision imports its predecessor and is a function of it. +**2 — revised.** The revision imports its parent and is a function of it. ```ts import prior from "./001-9f3c….ts" @@ -173,7 +173,7 @@ Both survive; both are reported with their authors and reasons. Nobody has to reconstruct why the plan disagreed, because both sides said so at the time. **4 — reconciled.** A reconciliation is an ordinary revision with more than one -predecessor, and a cross-plan reference is an ordinary import. +parent, and a cross-plan reference is an ordinary import. ```ts import { reconcile } from "compass" @@ -194,7 +194,7 @@ export default reconcile({ }) ``` -Every Step of every predecessor is carried forward, so the only thing this +Every Step of every parent is carried forward, so the only thing this version states is what actually changed: the fuzz step waits on the guard. Neither side's work can be dropped by choosing the other, which was the one operation capable of losing intent without leaving a trace of what it lost. diff --git a/examples/README.md b/examples/README.md index 3978d52..139b532 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ Three plans, worked end to end, in three different domains. Each is a real catalog: the version files are named by the hash of their own bytes, and each -revision imports its predecessor by that name — so the lineage is genuine, not +revision imports its parent by that name — so the lineage is genuine, not illustrative. Compass evaluates these: they are the acceptance suite (`tests/examples.rs`). @@ -30,7 +30,7 @@ export const fix = step({ work: "...", dependsOn: [measure], accept: /* ... export default plan({ author: "cos", goal: "...", why: "...", steps: [measure, fix] }) ``` -A revision imports its predecessor and is a function of it. It can edit, add, and +A revision imports its parent and is a function of it. It can edit, add, and retire — it has no way to *remove* a step, because every step is carried forward by the revision itself. A step that is no longer wanted is retired, and stays in the record marked as such. diff --git a/examples/two-machines/README.md b/examples/two-machines/README.md index ea0ef0f..29878c6 100644 --- a/examples/two-machines/README.md +++ b/examples/two-machines/README.md @@ -16,7 +16,7 @@ at the same time, each from `001`, neither having seen the other's work: adds a grammar guard for unterminated groups. When the two catalogs replicate together, both `002` versions arrive. They share -predecessor `001`, so they are a **divergence** — not a sequence, a disagreement. +parent `001`, so they are a **divergence** — not a sequence, a disagreement. Both survive; nothing is silently dropped. ``` @@ -31,7 +31,7 @@ Both survive; nothing is silently dropped. ## The reconciliation -`003` is an ordinary revision with **two** predecessors. Every step of both sides +`003` is an ordinary revision with **two** parents. Every step of both sides is carried forward — the fuzz step from A and the guard from B — so nothing is lost by "choosing a side", because there is no way to choose a side. The only thing `003` states is what actually changed: the fuzz run now depends on the @@ -45,10 +45,10 @@ added. ## What to look at in the files - **Two `002-` files with different hashes**, both importing `001`. That is a - divergence on disk — same predecessor, two contents, both admitted. Union + divergence on disk — same parent, two contents, both admitted. Union replication keeps both; neither overwrites the other. - **`003` imports both sides** and lists them in `revises`. A reconciliation is a - revision that names more than one predecessor; there is nothing else special + revision that names more than one parent; there is nothing else special about it. - **The reconciliation edits one step** (`fuzz.with({ dependsOn: [...] })`) and mentions nothing else. Everything unmentioned is carried forward. A reader sees From 8778b90e9d110ae4ef81ea3db71f82cebec1526e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:29:45 +0200 Subject: [PATCH 8/8] refactor(compass): align impl vocabulary with the refined ontology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename predecessor → parent across src and tests (helper fns, error strings, doc-comments, test names). The JSON already emitted `parents`; this closes the gap between the code's internal words and what it produces. - Rename receipt_json → commit_result_json: the output has no `receipt` key, and the ontology no longer defines Receipt — the returned value is the Plan Version. - Rewrite the readiness "On gates" note: the spec no longer says "dependencies and gates"; the ontology states the acceptance predicate *is* the gate. Frozen example modules under catalog/plans/*/versions/*.ts are untouched — they keep their period vocabulary, and the acceptance hash oracle (example_filenames_reproduce_from_source_bytes) confirms none drifted. cargo test: 15 passed. clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...001-compass-is-an-independent-authority.md | 7 ++- src/catalog.rs | 60 +++++++++---------- src/chain.rs | 26 ++++---- src/cli.rs | 2 +- src/cmd.rs | 30 +++++----- src/eval.rs | 8 +-- src/model.rs | 8 +-- src/prelude.js | 4 +- src/readiness.rs | 8 +-- tests/acceptance.rs | 18 +++--- 10 files changed, 83 insertions(+), 88 deletions(-) diff --git a/context/.decisions/0001-compass-is-an-independent-authority.md b/context/.decisions/0001-compass-is-an-independent-authority.md index 5c8a9cf..4237010 100644 --- a/context/.decisions/0001-compass-is-an-independent-authority.md +++ b/context/.decisions/0001-compass-is-an-independent-authority.md @@ -23,9 +23,10 @@ not planning. Every later question — where plans live, how they replicate, wha happens when the bus changes shape — inherits that coupling, and none of them can be answered on planning's own terms. -Defining opaque references, an idempotent port, and stable receipts before the -first authoritative write costs boundary work now and avoids an identity -migration later. Because no live plan state exists, that cost is at its minimum. +Defining opaque references, an idempotent write path, and stable version +identities before the first authoritative write costs boundary work now and +avoids an identity migration later. Because no live plan state exists, that cost +is at its minimum. ## Options diff --git a/src/catalog.rs b/src/catalog.rs index 948e343..edcb1a4 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -15,10 +15,10 @@ //! an error, never a warning. //! //! The identity is the hash of the raw bytes as they sit on disk. Because each -//! version imports its predecessors by filename, and each filename carries a +//! version imports its parents by filename, and each filename carries a //! hash of content, the lineage is walkable from the bytes alone: the parents of //! a version are the versions whose hash-prefix appears in its import -//! specifiers. A prefix that resolves to no local file is a missing predecessor +//! specifiers. A prefix that resolves to no local file is a missing parent //! (an orphan), repaired by waiting. use crate::event::Event; @@ -34,9 +34,9 @@ pub struct Admitted { pub hash: String, pub path: PathBuf, pub plan: String, - /// A reading aid: one past the longest predecessor. Never a key. + /// A reading aid: one past the longest parent. Never a key. pub seq: u64, - /// Predecessor references: a full hash when the predecessor is present, or + /// Parent references: a full hash when the parent is present, or /// the raw 12-hex prefix when it has not arrived (an orphan edge). pub parents: Vec, } @@ -186,7 +186,7 @@ pub fn load_plan(root: &Path, plan: &str) -> Result { } } - // Resolve import-prefixes to full predecessor hashes among the siblings. + // Resolve import-prefixes to full parent hashes among the siblings. let by_prefix: std::collections::HashMap<&str, &str> = raws .iter() .map(|r| (&r.hash[..crate::model::HASH_PREFIX_LEN], r.hash.as_str())) @@ -290,7 +290,7 @@ fn admit_version(path: &Path, expected_plan: &str) -> Result { } let source = std::str::from_utf8(&bytes).map_err(|e| format!("not valid UTF-8: {e}"))?; - let import_prefixes = predecessor_prefixes(source, path, expected_plan)?; + let import_prefixes = parent_prefixes(source, path, expected_plan)?; Ok(Raw { hash: actual, @@ -301,23 +301,19 @@ fn admit_version(path: &Path, expected_plan: &str) -> Result { }) } -/// The hash-prefixes of the *predecessor* version files a module imports, read -/// statically. A predecessor is a version of the SAME plan; a version of another +/// The hash-prefixes of the *parent* version files a module imports, read +/// statically. A parent is a version of the SAME plan; a version of another /// plan is a cross-plan reference (CMP.API-R05), not a parent, and is excluded -/// from the lineage so it never shows as an orphan predecessor edge. -fn predecessor_prefixes( - source: &str, - path: &Path, - expected_plan: &str, -) -> Result, String> { +/// from the lineage so it never shows as an orphan parent edge. +fn parent_prefixes(source: &str, path: &Path, expected_plan: &str) -> Result, String> { let specs = crate::eval::import_specifiers(source, path) .map_err(|e| format!("cannot read imports: {}", e.message()))?; let mut out = Vec::new(); for spec in specs { - // A predecessor import is a relative path to a version file. + // A parent import is a relative path to a version file. let file = spec.rsplit('/').next().unwrap_or(&spec); if let Some((_seq, prefix)) = parse_filename(file) { - // Only a same-plan version is a predecessor. A cross-plan reference + // Only a same-plan version is a parent. A cross-plan reference // (target plan differs) is not part of this plan's lineage. match crate::eval::import_target_plan(path, &spec) { Some(other) if other != expected_plan => continue, @@ -335,8 +331,8 @@ fn predecessor_prefixes( /// rejected — never reinterpreted into the Plan it was filed under — on the same /// terms as a version whose content hash disagrees with its own filename. /// -/// The origin is derived by walking resolved predecessor pointers within the -/// store (no evaluation, no extra IO) to the predecessor-less ancestor. When the +/// The origin is derived by walking resolved parent pointers within the +/// store (no evaluation, no extra IO) to the parent-less ancestor. When the /// walk reaches an ancestor that is absent locally the version is an orphan, not /// a misfiling: its Plan cannot yet be confirmed, so it is left alone. fn reject_misfiled(store: &mut PlanStore, plan: &str) { @@ -388,27 +384,27 @@ fn reject_misfiled(store: &mut PlanStore, plan: &str) { /// Derive a version's PlanId from its authored bytes (decision 0017). /// /// A Plan's identity is the content hash of its origin — the single -/// predecessor-less version. An origin (a module that imports no predecessor) is +/// parent-less version. An origin (a module that imports no parent) is /// its own PlanId: the hash of its bytes, the same hash its version filename -/// carries. A revision inherits its Plan from its predecessor: its origin is -/// found by walking the predecessor imports back to the predecessor-less version, +/// carries. A revision inherits its Plan from its parent: its origin is +/// found by walking the parent imports back to the parent-less version, /// and hashing that. The operator names nothing; identity is derived, and the /// prefix width matches the version filenames' for consistency. pub fn derive_planid(path: &Path, source: &[u8]) -> Result { let src = std::str::from_utf8(source) .map_err(|e| format!("{}: not valid UTF-8: {e}", path.display()))?; - let origin_bytes = match sibling_predecessor_paths(path, src)?.into_iter().next() { + let origin_bytes = match sibling_parent_paths(path, src)?.into_iter().next() { None => source.to_vec(), Some(pred) => walk_to_origin(&pred)?, }; Ok(crate::sha256::sha256_hex(&origin_bytes)[..crate::model::HASH_PREFIX_LEN].to_string()) } -/// The resolved paths of the *predecessor* version files a module imports — the +/// The resolved paths of the *parent* version files a module imports — the /// same-plan siblings, sitting in the importer's own directory. A cross-plan -/// reference resolves elsewhere and is not a predecessor, so it is excluded, as +/// reference resolves elsewhere and is not a parent, so it is excluded, as /// is the `compass` prelude (which is not a version reference). -fn sibling_predecessor_paths(path: &Path, source: &str) -> Result, String> { +fn sibling_parent_paths(path: &Path, source: &str) -> Result, String> { let dir = path.parent().unwrap_or_else(|| Path::new(".")); let specs = crate::eval::import_specifiers(source, path) .map_err(|e| format!("cannot read imports: {}", e.message()))?; @@ -427,28 +423,26 @@ fn sibling_predecessor_paths(path: &Path, source: &str) -> Result, Ok(out) } -/// Walk a predecessor chain to its origin and return the origin's raw bytes. -/// Any predecessor of a version shares that version's origin, so following one -/// predecessor at each step suffices. +/// Walk a parent chain to its origin and return the origin's raw bytes. +/// Any parent of a version shares that version's origin, so following one +/// parent at each step suffices. fn walk_to_origin(pred: &Path) -> Result, String> { let mut current = pred.to_path_buf(); let mut seen = std::collections::HashSet::new(); loop { if !seen.insert(current.clone()) { - return Err( - "predecessor lineage forms a cycle; a plan's identity cannot be derived".into(), - ); + return Err("parent lineage forms a cycle; a plan's identity cannot be derived".into()); } let bytes = fs::read(¤t).map_err(|_| { format!( - "predecessor {} has not arrived: a plan's identity is its origin, which cannot be \ + "parent {} has not arrived: a plan's identity is its origin, which cannot be \ derived until the origin is present (decision 0017)", current.display() ) })?; let src = std::str::from_utf8(&bytes) .map_err(|e| format!("{}: not valid UTF-8: {e}", current.display()))?; - match sibling_predecessor_paths(¤t, src)?.into_iter().next() { + match sibling_parent_paths(¤t, src)?.into_iter().next() { None => return Ok(bytes), Some(next) => current = next, } diff --git a/src/chain.rs b/src/chain.rs index 70c8a64..87ed2a4 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -8,9 +8,9 @@ //! The ontology is explicit and the distinction is the one Compass must not //! get wrong: //! -//! - **Divergence** — two or more versions share the same predecessor. Intent +//! - **Divergence** — two or more versions share the same parent. Intent //! genuinely disagreed. Repaired by authoring a Reconciliation. -//! - **Orphan** — a version whose predecessor is *absent locally*. Ordinarily +//! - **Orphan** — a version whose parent is *absent locally*. Ordinarily //! replication is simply incomplete. Repaired by **waiting**. //! //! Reconciling around a version that is merely in-flight writes permanent @@ -20,12 +20,12 @@ //! A version can be both a head member and an orphan: decision 0002 //! Amendment 1 describes receiving versions 1, 2 and 4, where 4's parent has //! not arrived. Head is then `{2, 4}` — and reporting that as divergence would -//! be a lie, because 2 and 4 share no predecessor. +//! be a lie, because 2 and 4 share no parent. use crate::catalog::{Admitted, PlanStore}; use std::collections::{BTreeMap, HashSet}; -/// A version whose predecessor is not present locally. +/// A version whose parent is not present locally. #[derive(Debug, Clone)] pub struct Orphan<'a> { pub version: &'a Admitted, @@ -33,10 +33,10 @@ pub struct Orphan<'a> { pub missing: Vec, } -/// Two or more versions sharing a predecessor. +/// Two or more versions sharing a parent. #[derive(Debug, Clone)] pub struct Divergence<'a> { - /// The shared predecessor hash, or `None` when several root versions exist. + /// The shared parent hash, or `None` when several root versions exist. pub parent: Option, pub children: Vec<&'a Admitted>, /// Whether this divergence is still unresolved. @@ -137,7 +137,7 @@ pub fn analyze(store: &PlanStore) -> Analysis<'_> { }) .collect(); - // Group by predecessor. Only predecessors that are actually present count: + // Group by parent. Only parents that are actually present count: // two versions both naming an absent parent are two orphans, not a // divergence we can reason about. let mut by_parent: BTreeMap<&str, Vec<&Admitted>> = BTreeMap::new(); @@ -274,7 +274,7 @@ pub fn lineage<'a>(store: &'a PlanStore, hash: &str) -> Vec<&'a Admitted> { out } -/// The `seq` a new version should carry: one past the longest predecessor. +/// The `seq` a new version should carry: one past the longest parent. pub fn next_seq(parents: &[&Admitted]) -> u64 { parents.iter().map(|p| p.seq).max().unwrap_or(0) + 1 } @@ -340,7 +340,7 @@ mod tests { } #[test] - fn a_missing_predecessor_is_an_orphan_not_a_divergence() { + fn a_missing_parent_is_an_orphan_not_a_divergence() { // Decision 0002 Amendment 1: versions 1, 2 and 4 arrive; 3 has not. let a = v("pl_1000000000", 1, "first", vec![]); let b = v("pl_1000000000", 2, "second", vec![a.hash.clone()]); @@ -353,7 +353,7 @@ mod tests { assert_eq!(an.head.len(), 2, "2 and 4 both lack a successor"); assert!( !an.diverged(), - "2 and 4 share no predecessor, so this is not divergence" + "2 and 4 share no parent, so this is not divergence" ); assert_eq!(an.orphans.len(), 1); assert_eq!(an.orphans[0].version.hash, d_hash); @@ -364,7 +364,7 @@ mod tests { #[test] fn two_versions_missing_the_same_parent_are_orphans_not_divergent() { - // The shared predecessor is absent, so nothing local proves they + // The shared parent is absent, so nothing local proves they // disagreed — only that replication is behind. let absent = "e".repeat(64); let a = v("pl_1000000000", 2, "one", vec![absent.clone()]); @@ -523,7 +523,7 @@ mod tests { } #[test] - fn lineage_stops_at_an_absent_predecessor() { + fn lineage_stops_at_an_absent_parent() { let absent = "d".repeat(64); let a = v("pl_1000000000", 2, "orphaned", vec![absent]); let tip = a.hash.clone(); @@ -532,7 +532,7 @@ mod tests { } #[test] - fn next_seq_follows_the_longest_predecessor() { + fn next_seq_follows_the_longest_parent() { let short = v("pl_1000000000", 2, "short", vec![]); let long = v("pl_1000000000", 9, "long", vec![]); assert_eq!(next_seq(&[&short, &long]), 10); diff --git a/src/cli.rs b/src/cli.rs index 63ddb67..ee3cca6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -328,7 +328,7 @@ compass commit - Committing content already present is a no-op success. - New content that revises nothing is refused, with a distinct message. - A module uses plan() for a first version, prior.revise({...}) for a - revision, or reconcile({revises:[...]}) for a reconciliation. Predecessors + revision, or reconcile({revises:[...]}) for a reconciliation. Parents are the version files it imports; the Plan is derived from the origin they descend from. " diff --git a/src/cmd.rs b/src/cmd.rs index 2c3bdb9..3dc9779 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -296,7 +296,7 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { // A Plan's identity is derived from its origin (decision 0017): the operator // names nothing. An origin is its own PlanId; a revision inherits its Plan - // from the predecessor it descends from. + // from the parent it descends from. let plan = catalog::derive_planid(path, &source)?; // Evaluate the authored module (imports resolve at its location). @@ -315,11 +315,11 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { .ok_or_else(|| "the module did not export a plan".to_string())?; // Classify each version import (CMP.API-R05). An import of a version of the - // SAME plan is a predecessor, resolved against this plan's store and made a + // SAME plan is a parent, resolved against this plan's store and made a // parent of the new version. An import of ANOTHER plan's version is a // cross-plan reference: it must resolve against that plan's store (the other - // version must be admitted), but it is not a predecessor and does not make - // this commit "have an uncommitted predecessor". + // version must be admitted), but it is not a parent and does not make + // this commit "have an uncommitted parent". let store = catalog::load_plan(root, &plan)?; let mut parents: Vec = Vec::new(); for spec in crate::eval::import_specifiers(&source_str, path) @@ -331,7 +331,7 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { }; // A target plan that differs from the current one is a cross-plan // reference; anything else (a sibling, or a version of this same plan) - // is a predecessor. + // is a parent. match crate::eval::import_target_plan(path, &spec) { Some(other) if other != plan => { let other_store = catalog::load_plan(root, &other)?; @@ -346,7 +346,7 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { Some(a) => parents.push(a.hash.clone()), None => { return Err(format!( - "predecessor {prefix} is not committed in {plan}; nothing was recorded" + "parent {prefix} is not committed in {plan}; nothing was recorded" )) } }, @@ -388,7 +388,7 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { ); return Ok(Output::ok( text, - receipt_json("already-committed", &plan, &hash, seq, &parents), + commit_result_json("already-committed", &plan, &hash, seq, &parents), )); } @@ -434,11 +434,11 @@ fn cmd_commit(root: &Path, path: &Path) -> Result { ); Ok(Output::ok( text, - receipt_json(kind, &plan, &hash, seq, &parents), + commit_result_json(kind, &plan, &hash, seq, &parents), )) } -fn receipt_json(kind: &str, plan: &str, hash: &str, seq: u64, parents: &[String]) -> Json { +fn commit_result_json(kind: &str, plan: &str, hash: &str, seq: u64, parents: &[String]) -> Json { Json::obj(vec![ ("command", Json::str("commit")), ("result", Json::str(kind)), @@ -570,7 +570,7 @@ fn divergence_report(an: &Analysis) -> String { let mut out = String::new(); for o in &an.orphans { out.push_str(&format!( - "{} {} is an orphan: predecessor {} has not arrived — wait\n", + "{} {} is an orphan: parent {} has not arrived — wait\n", style::warning(), style::short(&o.version.hash), o.missing @@ -582,7 +582,7 @@ fn divergence_report(an: &Analysis) -> String { } for d in an.open_divergences() { out.push_str(&format!( - "{} open divergence: {} head members share a predecessor — reconcile by authoring \ + "{} open divergence: {} head members share a parent — reconcile by authoring \ a version importing both\n", style::warning(), d.children.len() @@ -852,7 +852,7 @@ fn cmd_verify(root: &Path, plan: Option<&str>, all: bool) -> Result Result { )); } - // Identify the last intact predecessor to continue from. + // Identify the last intact parent to continue from. let intact: Vec<&Admitted> = store .versions .iter() @@ -973,7 +973,7 @@ fn cmd_repair(root: &Path, plan: &str) -> Result { Some(b) => { let rel = crate::model::filename_for(b.seq, &b.hash); text.push_str(&format!( - "\nAuthor a damage-recording version continuing from the last intact predecessor \ + "\nAuthor a damage-recording version continuing from the last intact parent \ ({}):\n\n import prior from \"./{}\"\n export default prior.revise({{\n \ author: \"you\",\n why: \"Records the damage to and continues.\",\n \ }})\n\nthen `compass commit` it. Verification stays read-only.\n", @@ -983,7 +983,7 @@ fn cmd_repair(root: &Path, plan: &str) -> Result { } None => { text.push_str( - "\nNo intact predecessor remains; author a fresh plan recording what is known \ + "\nNo intact parent remains; author a fresh plan recording what is known \ of the lost intent.\n", ); } diff --git a/src/eval.rs b/src/eval.rs index 3bb347a..7b7c568 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -493,7 +493,7 @@ fn first_diag(errs: &[T]) -> String { /// The import specifiers a module declares, statically (no evaluation). /// -/// A version references its predecessors by importing their files, so the +/// A version references its parents by importing their files, so the /// lineage can be walked from source bytes alone — admission never runs a module /// (02-artifacts). Returns every specifier, including `"compass"`. pub fn import_specifiers(source: &str, path: &Path) -> Result, EvalError> { @@ -601,7 +601,7 @@ enum Resolved { /// /// Resolution of a *well-formed* version reference must still succeed when the /// file is merely absent, so the loader is invoked and reports it as Unresolved -/// (a predecessor that has not arrived) rather than being failed here. +/// (a parent that has not arrived) rather than being failed here. /// /// The path is normalised lexically, never against the real filesystem, so no /// symlink or `..` can redirect resolution outside the catalog. @@ -716,7 +716,7 @@ fn is_cross_plan_version(target: &Path, base: &Path) -> bool { /// and the specifier — `None` when the specifier is not a version reference or /// its target plan cannot be determined (e.g. a sibling outside the catalog /// layout, which the caller treats as the current plan). This is the single -/// source of truth both the resolver (Fix 1) and commit's predecessor logic +/// source of truth both the resolver (Fix 1) and commit's parent logic /// (Fix 2) classify against, so they cannot drift. pub(crate) fn import_target_plan(base: &Path, spec: &str) -> Option { let dir = base.parent().unwrap_or_else(|| Path::new(".")); @@ -731,7 +731,7 @@ pub(crate) fn import_target_plan(base: &Path, spec: &str) -> Option { /// Verify a resolved import is an admitted plan version: its content hash must /// match the hash embedded in its filename (02-artifacts). A missing file is -/// Unresolved (a predecessor that has not arrived); present-but-mismatched is a +/// Unresolved (a parent that has not arrived); present-but-mismatched is a /// Failed read (a non-admitted or tampered file the plan must not evaluate). fn verify_admitted(name: &str) -> Result<(), EvalError> { let path = Path::new(name); diff --git a/src/model.rs b/src/model.rs index 62f2f74..b348347 100644 --- a/src/model.rs +++ b/src/model.rs @@ -62,7 +62,7 @@ impl Step { /// An immutable snapshot of a Plan's structural intent, as evaluated. /// /// `plan`, `seq`, and `parents` are supplied by the catalog (the plan is the -/// directory, the parents are the imported predecessor files, the seq is a +/// directory, the parents are the imported parent files, the seq is a /// reading aid). Everything else is declared by the module and recovered by /// evaluation. #[derive(Debug, Clone, PartialEq, Eq)] @@ -70,7 +70,7 @@ pub struct Version { pub plan: String, /// Position along this version's lineage. A reading aid, never a key. pub seq: u64, - /// Content hashes of each predecessor (resolved), sorted. + /// Content hashes of each parent (resolved), sorted. pub parents: Vec, pub author: String, /// Required Rationale (CMP-R03). @@ -82,7 +82,7 @@ pub struct Version { impl Version { /// Assemble a domain version from an evaluated module plus the catalog-side - /// facts (which plan, which lineage position, which predecessors). + /// facts (which plan, which lineage position, which parents). pub fn from_sem(plan: &str, seq: u64, parents: Vec, sem: &SemVersion) -> Version { let mut parents = parents; parents.sort(); @@ -116,7 +116,7 @@ impl Version { pub fn validate(&self) -> Result<(), String> { // A goal is required on every version and is the Plan's human handle // (CMP.DM-R18, decision 0017). It is checked on the evaluated value, so a - // revision that inherits its predecessor's goal passes, and one that + // revision that inherits its parent's goal passes, and one that // states an empty goal is refused. if self.goal.trim().is_empty() { return Err( diff --git a/src/prelude.js b/src/prelude.js index bf390e9..a8f833a 100644 --- a/src/prelude.js +++ b/src/prelude.js @@ -130,7 +130,7 @@ function plan(spec) { } // Apply the edit / add / retire triad against a carried-forward step list. -// There is no fourth operation: a step of a predecessor is carried forward +// There is no fourth operation: a step of a parent is carried forward // unless it is edited or retired, so dropping a step has no spelling. function applyOps(steps, rev) { const edit = rev.edit || []; @@ -215,7 +215,7 @@ function canonStep(s) { }); } -// A reconciliation is a revision with more than one predecessor. Every step of +// A reconciliation is a revision with more than one parent. Every step of // every side is carried forward, keyed by identity, so nothing is lost by // choosing a side; the version states only what it changes. // diff --git a/src/readiness.rs b/src/readiness.rs index 01dae1b..13dade5 100644 --- a/src/readiness.rs +++ b/src/readiness.rs @@ -22,10 +22,10 @@ //! //! ## On "gates" //! -//! The spec names "dependencies and gates" as the two things readiness folds -//! over, but defines no gate concept anywhere. This implementation treats the -//! acceptance predicate as the gate — it is the only authored condition a step -//! carries besides its dependencies. Noted as a spec ambiguity. +//! The ontology defines no separate gate concept: the acceptance predicate *is* +//! the gate — the only authored condition a step carries besides its +//! dependencies. Readiness folds over dependencies and unmet criteria, and this +//! implementation treats a step's acceptance as what gates its dependents. use crate::event::{Event, EventKind}; use crate::model::{Step, Version}; diff --git a/tests/acceptance.rs b/tests/acceptance.rs index 2b34add..69195fb 100644 --- a/tests/acceptance.rs +++ b/tests/acceptance.rs @@ -202,7 +202,7 @@ export default plan({ author: "cos", goal: "Ship the widget", why: "It is time." .unwrap(); assert!(again.text.contains("already committed"), "{}", again.text); - // revise — a function of the predecessor, carrying every step forward. + // revise — a function of the parent, carrying every step forward. let rev = format!( r#"import {{ step, evidence }} from "compass" import prior from "./{v1}" @@ -219,7 +219,7 @@ export default prior.revise({{ .text .contains("carefully")); - // diverge: two revisions from the same predecessor v2. + // diverge: two revisions from the same parent v2. let side_a = format!( r#"import {{ step, evidence }} from "compass" import prior from "./{v2}" @@ -409,10 +409,10 @@ export default prior.revise({{ author: "cos", why: "reword", edit: [prior.steps. ); } -// ---- Fix 2: a cross-plan reference commits and is not a predecessor ---- +// ---- Fix 2: a cross-plan reference commits and is not a parent ---- #[test] -fn a_cross_plan_reference_is_not_a_predecessor() { +fn a_cross_plan_reference_is_not_a_parent() { let root = std::env::temp_dir().join(format!("compass-xplan-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let _g = Tmp(root.clone()); @@ -428,7 +428,7 @@ export default plan({ author: "cos", goal: "dep", why: "the referenced plan", st // A first version of the main plan that references the dep plan's version // cross-plan, by its PlanRef directory. The reference is real (it reads a - // value from the other plan's version) but is not a predecessor: no parent. + // value from the other plan's version) but is not a parent: no parent. let main_src = format!( r#"import {{ plan, step, evidence }} from "compass" import dep from "../../{dep_plan}/versions/{dep_v1}" @@ -436,7 +436,7 @@ export const local = step({{ work: "Local, mirrors " + dep.steps.seed.work, acce export default plan({{ author: "cos", goal: "main", why: "references dep cross-plan", steps: [local] }}) "# ); - // The main plan has no predecessor, so its PlanRef is its own hash — author + // The main plan has no parent, so its PlanRef is its own hash — author // the draft in that dir so the cross-plan `../../` reference resolves. let main_plan = planref_of(&main_src); let vdir = catalog::versions_dir(&root, &main_plan); @@ -454,7 +454,7 @@ export default plan({{ author: "cos", goal: "main", why: "references dep cross-p assert_eq!(out.code, 0, "{}", out.text); assert!( out.text.contains("created"), - "a cross-plan reference has no predecessor, so this is a creation: {}", + "a cross-plan reference has no parent, so this is a creation: {}", out.text ); @@ -651,7 +651,7 @@ fn an_origin_files_under_its_own_hash_and_a_revision_shares_the_planref() { ); assert!(catalog::plan_dir(&root, plan).is_dir()); - // A revision, authored as a sibling of its predecessor, inherits the Plan. + // A revision, authored as a sibling of its parent, inherits the Plan. let rev = format!( r#"import prior from "./{v1}" export default prior.revise({{ author: "cos", why: "Reword.", edit: [prior.steps.build.with({{ work: "Build it, carefully" }})] }}) @@ -726,7 +726,7 @@ fn a_commit_with_an_empty_goal_is_refused() { export const a = step({ work: "x", accept: evidence.test({ status: "pass" }) }) export default plan({ author: "cos", goal: "", why: "w", steps: [a] }) "#; - // An origin has no predecessor import, so it may be authored anywhere. + // An origin has no parent import, so it may be authored anywhere. let draft = tmp_module(&root, "empty-goal.ts", empty_goal); let err = match run(&root, Command::Commit { path: draft }) { Ok(o) => panic!("an empty goal must be refused, got: {}", o.text),