@ybotok/loam 0.0.0 → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
package/SCHEMA.md
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# loam docs-repo schema
|
|
2
|
+
|
|
3
|
+
This documents the layout of the **docs repo** — the single shared source of truth that `loam` reads and writes. A runnable example lives under [`examples/docs/`](examples/docs).
|
|
4
|
+
|
|
5
|
+
Everything here is plain files. `loam` derives views and indexes from them; delete `loam` and the docs remain.
|
|
6
|
+
|
|
7
|
+
## Layer stack
|
|
8
|
+
|
|
9
|
+
C4 is the center. Each artifact is a **source** (authored), a **hybrid** (agent-written at bootstrap, authored forward), or a **derived** view (generated from the model + spine).
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
presentation │ UI page-prototypes ─(consume)─► endpoints spec = source · proto = derived [later]
|
|
13
|
+
behavior │ gherkin scenarios ──(govern)──► pages source
|
|
14
|
+
arch behavior│ arch.spec.md ──(covers)──► C4 · health source
|
|
15
|
+
contract │ OpenAPI ◄──(detail of C4 "exposes/calls") hybrid
|
|
16
|
+
structure │ C4 model — services, relationships source (adopt-seeded)
|
|
17
|
+
ops / why │ ADR · runbook · health source
|
|
18
|
+
truth │ code ground truth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
All artifacts are wired by one **ID spine**: `FEAT-<id>`, service id, and C4 element ids, carried in frontmatter and LikeC4 tags.
|
|
22
|
+
|
|
23
|
+
## Layout
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
docs/
|
|
27
|
+
AGENTS.md the process contract, travelling with the docs
|
|
28
|
+
architecture/
|
|
29
|
+
landscape.likec4 global C4 (fleet landscape)
|
|
30
|
+
landscape.health.yaml composed health model [derived, later]
|
|
31
|
+
services/<svc>/
|
|
32
|
+
model.likec4 service C4 (containers/components) [adopt]
|
|
33
|
+
spec.md living requirements (Requirement/Scenario) [adopt]
|
|
34
|
+
arch.spec.md living ARCHITECTURE requirements (outbox, retries, alerts; Covers:) [authored]
|
|
35
|
+
openapi.yaml API contract [adopt / authored]
|
|
36
|
+
adrs/NNNN-*.md MADR decisions [adopt-seed / authored]
|
|
37
|
+
runbook.md operational runbook [adopt-draft / authored]
|
|
38
|
+
health.yaml SLI/SLO, checks, critical deps [authored]
|
|
39
|
+
ui/pages/*.page.yaml page-specs (UI services) [later]
|
|
40
|
+
flows/ interaction flows -> sequence views [later]
|
|
41
|
+
features/<FEAT>/
|
|
42
|
+
intent.md business intent / proposal (why) [authored]
|
|
43
|
+
delta.likec4 C4 delta (architecture) [authored]
|
|
44
|
+
specs/<svc>/spec.md requirement delta (ADDED/MODIFIED/REMOVED + scenarios) [authored]
|
|
45
|
+
specs/<svc>/arch.spec.md architectural requirement delta, same algebra [authored]
|
|
46
|
+
specs/<svc>/openapi.yaml the endpoints this feature adds [authored]
|
|
47
|
+
adrs/NNNN-*.md feature-level decisions [authored]
|
|
48
|
+
verification.yaml the done-check: claims + verdicts + evidence, one attestation per service [loam verify --service <id> --record]
|
|
49
|
+
features/archive/<FEAT>/ a shipped change — the same files, plus:
|
|
50
|
+
.loam-before/ the bytes the merge overwrote [written by loam]
|
|
51
|
+
.loam-lock transient: one writer at a time [archive/unarchive only]
|
|
52
|
+
.loam-commit transient: the commit intent journal [archive/unarchive only]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The last two exist only inside an `archive`/`unarchive` commit window and are removed after it. Both belong in the docs repo's `.gitignore`. Finding one at rest means a write did not finish: `loam doctor` grades it, and the next `archive`/`unarchive` repairs or refuses.
|
|
56
|
+
|
|
57
|
+
## `loam.json` — the wiring, one file per repository
|
|
58
|
+
|
|
59
|
+
The docs repo is one repository; each service is another. Every one of them carries a **committed** `loam.json` at its root, and that file is the only thing joining them: there is no environment variable, no global config, no registry. A command that finds no `loam.json` at or above its working directory refuses with `no-config` rather than guessing which fleet it is in. (Discovery walks upward from the cwd to the repository root, so running loam from a subdirectory works; `loam init` writes the file where it is standing.)
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{ "docsDir": "../docs-repo", "service": "payment-service", "gherkinDir": "features" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **`docsDir`** (required) — where the docs repo is. It is stored **exactly as it was passed** and resolved against the directory holding the `loam.json`, so `../docs-repo` keeps resolving on every machine that checks the two repos out side by side. That is the whole reason the file is committed, and why an absolute path is a warning (`doctor.docs-absolute`): it resolves only on the machine that ran `loam init`. The docs repo's own file says `"docsDir": "."` — see the bootstrap step under "Operating at fleet scale". A `docsDir` pointing at nothing refuses with `docs-missing`; one pointing at a directory with no `services/` refuses with `services-missing` (most often the service repo itself, after a typo), because an unreadable fleet and an empty fleet are different facts and only one of them is fine.
|
|
66
|
+
- **`service`** (required in a service repo, absent in the docs repo) — the canonical id of the service this repository contains: the `services/<id>/` directory it is allowed to speak for. `loam vouch`, `loam gherkin` and `loam verify --service` all bind to it and refuse (`repository-unavailable`) without it — a stamp, a generated suite or an attestation has to be tied to the code it is about. It must be a legal directory name under the same grammar `--service` is checked against (`[A-Za-z0-9][A-Za-z0-9._-]*`); anything else is `config-invalid`.
|
|
67
|
+
- **`gherkinDir`** (optional, default `features`) — the directory `loam gherkin` writes its own `loam/` subtree into.
|
|
68
|
+
|
|
69
|
+
`loam init --docs <dir>` **joins** an existing docs repo (one with `services/` and `AGENTS.md`); creating a new one requires `--create`, so a mistyped path cannot quietly scaffold a second source of truth beside the real one. `--docs` wins only when it is actually passed: a re-run in a repo whose committed `loam.json` already names a `docsDir` keeps that pointer, `--create` included, and spreads the rest of the config forward. Otherwise following `doctor`'s own advice to re-run `init` would repoint a wired repo at an empty decoy, over which `validate --all` goes green. `loam init --json` says which source won, as `docsDirSource: "flag" | "config" | "default"`. `loam doctor` is the read-only preflight over all of the above: it reads the file defensively (corrupt JSON included), resolves `docsDir`, probes access, checks `services/` and the fleet map, and reports the binding — every finding carrying a concrete `fix`.
|
|
70
|
+
|
|
71
|
+
## Conventions
|
|
72
|
+
|
|
73
|
+
**Frontmatter**: `status`, `owner`, `service` or `feature`, `last_verified`, `sources` (literal files and directories), `sources_digest`, `content_digest`, `sources_files`. `loam validate` reads it in exactly three files — a service's living `spec.md`, its living `arch.spec.md` when present (same conventions, same checks, only the label differs), and a feature's `intent.md`: the documents whose identity and status everything else joins on. `adrs/` and `runbook.md` are presence-tracked only (`loam list` says whether they exist); their frontmatter is read by nothing today, so a field written there is a note to a future reader, not a claim any check will catch. `health.yaml`'s frontmatter is equally unread, but the file itself is not inert: `loam validate` reads its top-level `slis:`/`alerts:` ids for `Covers:` resolution and the `health.uncovered` warning (see the architecture spec axis below). For the two checked files:
|
|
74
|
+
|
|
75
|
+
- `status` — services: `draft` -> `verified`; features: `proposed` -> `in_progress` -> `built` -> `done`. An undocumented value is an **error**: a typo (`verifed`) would otherwise read as unverified forever.
|
|
76
|
+
- a frontmatter block that does not parse as YAML is an **error** of its own (`frontmatter.malformed`), and the only finding that file's header produces: the fields are unreadable, not absent, and the cascade the silent parse used to trigger — "owner missing", "status missing", "names no sources" — sent authors adding lines to a block YAML refuses. The field checks and the sources chain resume once the header parses.
|
|
77
|
+
- `service` / `feature` — must match the directory the file lives under. A mismatch is an **error**; absence is a warning.
|
|
78
|
+
- `sources` — paths in the *service's own repo* the artifact was written from: literal files and directories only, a directory meaning everything beneath it. Resolved when `loam` runs inside that repo (`service` in `loam.json`); a path that no longer exists is an **error**, and so is a glob-looking entry (`*`, `?` or `[`) — patterns are not supported, and are refused loudly rather than matched, because a pattern dialect nobody can be sure of digests a different file set than its author intended, corrupting the trust signal silently. This is the only mechanical tie between the docs and the code: everything else loam checks is internal consistency, which a fluent fiction satisfies too.
|
|
79
|
+
- `sources_digest` / `last_verified` — written by `loam vouch`, never by hand. The digest is a content hash of the files `sources` names, taken when a human last read them; `loam validate`, run in that repo, recomputes it and reports `sources.current`, `sources.stale` (the code moved since anyone looked), or `sources.unvouched` (`sources` with no digest — nobody has ever stamped it). All warnings: staleness is a signal, and only a person can say whether the doc is still right.
|
|
80
|
+
- `content_digest` — written by `loam vouch`, never by hand, like its siblings: a hash of the document's OWN body, so the stamp covers the words as well as the code. Editing a spec after it was vouched used to leave `status: verified` standing over prose nobody read — the exact forgery an agent-written corpus invites. `loam validate` recomputes it wherever the doc is readable; this check needs **no service repo**, so unlike the `sources.*` chain it runs for `--service` and `--all` alike, from the docs repo included. A `verified` spec whose current body hash mismatches gets `content.stale` (**warning**, the `sources.stale` grading: the doc changed since it was vouched, and only a person can say whether verified still holds). A verified doc with no `content_digest` predates the stamp and stays quiet — the fix is re-vouching.
|
|
81
|
+
- Missing fields are warnings, and `loam list` reports the fleet's draft/verified/unmarked split.
|
|
82
|
+
|
|
83
|
+
**Vouching** (`loam vouch --service <id>`, run in the service's own repo) is the human promotion `draft` -> `verified`: it stamps `status`, `last_verified`, `sources_digest`, `content_digest` and `sources_files` together — into the living `spec.md`, and into `arch.spec.md` beside it when the service has one — rewriting only the frontmatter. It refuses what it cannot verify — a frontmatter block that will not parse as YAML (its fields are unreadable, and a stamp would have to rewrite the header wholesale, losing the author's lines), no `sources`, a glob-pattern entry (not supported), a source path that is gone, sources expanding to no files at all, or a repo that is not that service's — checked per file, the refusal naming which, and all-or-nothing per service: one unverifiable file refuses the whole run, so a `verified` status always has a digest behind it and never means half the pair was stamped. The write keeps the same promise: the pair is committed through staged temp-file swaps (archive's machinery), so a failure between the two files rolls the first back (`merge-failed` — nothing stamped, re-run) instead of leaving half a stamp; only a rollback that itself fails leaves the repo to a human (`rollback-incomplete`, the message naming the files). And because the stamp is computed from bytes read a moment earlier, the swap re-checks them: a document that changed under the vouch — a concurrent vouch, a hand edit — refuses with `vouch-raced` and writes nothing, rather than stamping a digest of text that is no longer there.
|
|
84
|
+
|
|
85
|
+
The digest recipe (stable, and part of the contract): expand `sources` to repo-relative file paths — a file is itself, a directory means everything beneath it, dot-entries are skipped while walking (though a path naming one outright is honoured) — sort them, hash each file's bytes with sha256, feed `<path>\0<sha256-hex>\n` per file into an outer sha256, and keep the first 16 hex characters.
|
|
86
|
+
|
|
87
|
+
Two exclusions are part of that expansion, because a digest is a *staleness signal* and both of these turned it into noise:
|
|
88
|
+
|
|
89
|
+
- **What git ignores is not hashed.** `node_modules` is skipped by name unconditionally, and the rest is asked of git itself — one `git check-ignore` over the expanded list, run in the service repo. A build output tree under a listed source used to flip `sources.stale` on every compile, which is the fastest way to teach a team to ignore the signal. The fallback direction is deliberate: if git cannot answer (not a repository, git absent, the call times out) **everything is hashed**, because a missed exclusion costs noise while an invented one costs a file the stamp silently stops watching. A `sources` entry that expands to nothing after this filter is `sources.empty` (warn) — a digest over no files reads as current forever — and `loam vouch` refuses to stamp it with the same sentence.
|
|
90
|
+
- **Symlinks are followed only inside the repository.** A link to a file inside the repo is hashed under the link's own spelling; a link to a directory inside the repo is walked, with a stack of realpaths as the cycle guard (a global visited-set would make the file set depend on `readdir` order when one directory is reachable by two spellings — two machines with identical bytes, two different digests). A link whose target is outside the repository, dangling, or neither a file nor a directory is **not** hashed and is reported instead: `sources.skipped` (warn), naming each path and why. It is not closable by editing the document — either the link should not be under a listed source, or `sources` should name the real path.
|
|
91
|
+
|
|
92
|
+
Alongside the digest, `loam vouch` stamps **`sources_files`**: the per-file index (`<sha256> <path>` lines, sha256sum layout, in a block scalar) the digest was computed over. It is what lets a later `sources.stale` say *which* paths were added, changed or removed instead of only that something moved. Above `SOURCE_INDEX_LIMIT` (100) files the field records the count alone — a readability budget — and an absent or unparseable field reads as "the stamp said nothing about individual files", explicitly not "every file was deleted". Like its siblings it is written by `loam vouch` and never by hand. Glob patterns are not part of the recipe: an entry containing `*`, `?` or `[` never expands — `loam vouch` refuses it and `loam validate` errors on it — because a pattern dialect nobody can be sure of silently digests a different file set than intended, corrupting the trust signal in both directions. (The literal-path expansion is unchanged from when patterns existed, so documents vouched with literal paths keep their digests.) Content, not mtime: git does not preserve modification times, so an mtime check would call every file stale after a fresh clone.
|
|
93
|
+
|
|
94
|
+
The body digest recipe (`content_digest`, same discipline): sha256 of the artifact's body bytes AFTER the frontmatter block — the closing `---` line and its newline belong to the header, every byte below them is body — first 16 hex characters kept. Byte-exact, no normalization, and body-only on purpose: vouch itself rewrites the frontmatter as it stamps, so a header-only edit (a later vouch, a corrected `owner`) must not read as the document moving.
|
|
95
|
+
|
|
96
|
+
**Tags (LikeC4)**: element kinds and tags are declared in a `specification` block; a delta's new/changed elements carry the feature id as a tag (`#FEAT-101`) so `loam` can project the delta by tag and validate it. The tag is loam's only way of seeing the delta, so a `delta.likec4` that declares elements or relationships with none carrying the feature's tag is an **error** (`delta.nothing-tagged`): everything in it is invisible to the checks, and the archive would merge nothing.
|
|
97
|
+
|
|
98
|
+
**Service binding (element ↔ `services/<svc>/`)**: an element says which service it *is* with `metadata { service 'payment-service' }`. Without one, its **title** is used — which is what every existing repo relies on, and also the trap: rename a box in a diagram and every check that joined it to its directory silently stops matching. The binding is what makes a rename safe.
|
|
99
|
+
|
|
100
|
+
There is no manifest of services: the directories under `services/` **are** the list. `loam validate --all` cross-checks that list against the landscape in both directions:
|
|
101
|
+
|
|
102
|
+
- a `services/<svc>/` no element resolves to → `landscape.service-unmodelled` (**error**): the fleet map is incomplete, and every view derived from it is wrong;
|
|
103
|
+
- an element that looks like a service, with no directory → `landscape.service-undocumented` (**warning**): it may be someone else's system. Tag it `#external` to say so, and the warning stops. **Depth is not a fact about a service.** The check used to keep only top-level elements, which ordinary grouped C4 breaks the moment services sit under an `enterprise`/`group`/`boundary` parent: every nested element was discarded, so a perfectly bound service read as unmodelled. The tree is walked instead — an element is graded at service level when no *ancestor* already stands for a service (its binding first, then a title naming a real `services/<id>/`), and what sits inside one of those is that service's container. Two consequences worth stating: an element that CONTAINS a service is a grouping, has nothing to bind and nothing to tag, and never raises this warning; and a `person`-kind element is exempt as before;
|
|
104
|
+
- an element whose explicit binding names a directory that does not exist → `landscape.binding-unknown` (**error**), graded **at any depth**, not only at top level — a typo one level down used to bind an edge to a service that does not exist with nothing saying so. A binding is a claim about this repo; a title that fails to match is only a guess at one, which is why the two are graded differently;
|
|
105
|
+
- two elements resolving to the **same** `services/<svc>/` → `landscape.binding-duplicate` (**warning**): every element→service join picks one of them arbitrarily, so the other's edges are quietly filed under a service that does not own them.
|
|
106
|
+
|
|
107
|
+
**`architecture/landscape.likec4` is required, not optional.** Its absence is a finding, never a skipped check: an **error** as soon as `services/` holds at least one service directory, and a **warning** in a docs repo whose `services/` is still empty (`landscape.missing`, naming the file and what to write in it — a `specification` block declaring the kinds, a `model` block with one bound element per service, one edge per cross-service call carrying `metadata { op }`). The reason it cannot be silence: with no map, the whole cross-service layer — the C4↔API spine, `landscape.service-unmodelled`, every derived view — computes nothing and *reports* nothing, which reads exactly like a clean fleet. `loam init` scaffolds the file (empty, commented, parsing clean) so a fresh docs repo starts from a real one rather than from that ambiguity, and `loam adopt` briefs the block each service still owes it.
|
|
108
|
+
|
|
109
|
+
Absences inside a service directory are graded too. A directory with no `spec.md` is a **warning** (`service.no-spec`) — partial adoption is a supported state, but the checks that file feeds are silently vacuous without it, and that has to be said.
|
|
110
|
+
|
|
111
|
+
A missing `openapi.yaml` is graded on what already points into it, under one code, `service.no-openapi`, at three grades:
|
|
112
|
+
|
|
113
|
+
- **silence**, on positive evidence that nobody expects an API there: the landscape parses and no op-carrying edge targets the service;
|
|
114
|
+
- **warning**, when nothing joins into the file and the landscape cannot prove nobody calls the service (absent or unparseable) — "I could not look" and "nobody calls me" are different facts and only one of them is fine;
|
|
115
|
+
- **error**, when something already joins into it: a living non-`REMOVED` requirement carrying an `Operations:` line, or an op-linked landscape edge whose target resolves here. Those operationIds ride in the finding's `details`. A requirement governing an operation that resolves to nothing is the same breach `spec-api.op-undefined` grades when the file exists and the op does not; the file being absent is not a reason to grade it lower.
|
|
116
|
+
|
|
117
|
+
And `loam validate --service <id>` for a directory that does not exist at all is an **error** (`service.unknown`) that suggests close ids — never `loam adopt`, which would steer an agent into adopting the typo. A `features/<FEAT>/specs/<svc>/` directory addressing a service that neither exists as `services/<svc>/` nor is introduced by that feature's own tagged `delta.likec4` is the same class of mistake and gets its own **error**, `delta.service-unknown`, naming close ids: archiving would otherwise materialise a phantom service directory out of a typo in `--touches`. A delta that did not parse suspends the question rather than guessing (`delta.invalid` is that finding).
|
|
118
|
+
|
|
119
|
+
**API linkage (operationId)**: OpenAPI's `operationId` joins architecture and behavior — it is the token both C4 and specs reference.
|
|
120
|
+
- a C4 relationship names the operation it calls: `... -> ... 'Calls createSplit' { metadata { op 'createSplit' } }`;
|
|
121
|
+
- a requirement declares the operations it governs: an `Operations: createSplit` line;
|
|
122
|
+
- the operation is defined in the provider service's `openapi.yaml`.
|
|
123
|
+
|
|
124
|
+
`loam validate` then checks the **contract** (every edge's `op` exists in the target's OpenAPI — consumer-driven, gates) and **coverage** (every operation is governed by a requirement). Only the eight HTTP methods of a path item are read for operationIds — an id tucked inside a vendor extension (`x-legacy:`) does not exist, because a phantom id would mask a contract break. A service whose openapi defines operations while not one requirement carries an `Operations:` line gets a single **warning** (`api.ops-unlinked`): the API axis is unchecked there until someone links it — the exact state an adopted OpenSpec repo starts in. An `openapi.yaml` that exists but does not parse is an **error** (`openapi.invalid`) — including one whose bytes are not valid UTF-8, which is graded as unreadable rather than handed to the YAML parser with U+FFFD substituted in. The checks that read the contract — the `api.*` family and the spine's op resolution — are suspended for that service: an unreadable contract used to read as an EMPTY one, grading every inbound edge `spine.op-undefined` when the truth was the file. **A missing file suspends them the same way**, and for the same reason: a contract that is not there proves nothing about an edge, neither broken nor resolved. So one absent contract is exactly one finding (`service.no-openapi`, carrying the stranded operationIds) rather than one error per inbound consumer, and no `spine.resolved` is claimed either.
|
|
125
|
+
|
|
126
|
+
Two `operationId`s that are the same in one document is a **warning** (`openapi.duplicate-operationid`), naming both `METHOD /path` slots — in a living contract and in a feature delta alike, one breach with one name wherever it is found. The document is legal OpenAPI; the ambiguity is in everything that joins on the id, each of which picks one slot arbitrarily.
|
|
127
|
+
|
|
128
|
+
**Deprecation is visible on the spine.** Real fleets migrate — legacy operations coexist with their replacements — and OpenAPI already carries the standard per-operation `deprecated: true` flag, so loam reads it (nothing else changes in what the reader touches: still the eight HTTP methods, still the operationId) and turns it into lifecycle warnings, letting the spine say "migrating off" instead of only alive-or-broken. A landscape edge calling a deprecated operation warns per inbound edge (`spine.op-deprecated`: the contract holds, but the consumer should be migrating — two op-linked edges from one consumer are two warnings, and a deprecated op nobody calls raises no spine warning); a requirement whose `Operations:` list resolves only to deprecated operations warns once (`api.requirement-deprecated`: the behaviour it governs is on its way out — migrate it to the replacement operation, or retire it); and a feature delta whose NEW tagged edge consumes an op the living provider contract deprecates warns in feature scope (`c4-api.op-deprecated`, advisory — it never gates `archive`: building new consumption on a dying op deserves an eye, not a block; quiet when the feature's own openapi delta restates the op without the flag — that feature IS the un-deprecation, and the warning family stops once it archives). All three are warnings, and `--strict` is the escalation.
|
|
129
|
+
|
|
130
|
+
**Removal is an explicit feature delta.** To retire an operation after deprecation, put the exact living path and HTTP method in the feature's `specs/<svc>/openapi.yaml`, keep its `operationId`, and add `x-loam-remove: true` **inside that operation object**, beside its `operationId`. A marker one level up, at path level beside the methods, names no operation: it retires nothing, and it is not a contract key either. `loam archive` gates it (`openapi.remove-marker-path-level`, error, overridable with `--approve` like the other judgement gates) and the marker never reaches the living document under any flag — `x-loam-remove` and `x-loam-based-on` are stripped at path level and operation level alike, on every merge branch. A path item consisting of nothing but a marker publishes no phantom path. The same feature must carry a `REMOVED` requirement whose `Operations:` line names that id. Coherence gates an absent path+method, an id mismatch, an unjustified marker, a removed requirement with no marker, and a new tagged C4 edge consuming the operation being removed. `serviceOperationIds` evaluates the feature as living upserts minus removals, so other same-feature checks see the post-archive contract. Archive deletes only the matching method, reports removals in text and `openapiRemovals` JSON, never copies the marker into living OpenAPI, and deliberately does not garbage-collect components. A removal marker in a living `openapi.yaml` is `openapi.remove-marker-living` (error). The usual staged-write rollback and `unarchive` snapshot restore apply byte-for-byte.
|
|
131
|
+
|
|
132
|
+
**Page-specs** (`ui/pages/*.page.yaml`) are `[later]`: an example ships under `examples/docs/`, but no command reads pages yet, so the file's format is illustrative rather than contract. What is settled is only the job — a page names the operations it consumes and the scenarios that govern it, joining the same spine as everything else.
|
|
133
|
+
|
|
134
|
+
## Living spec vs delta (OpenSpec model)
|
|
135
|
+
|
|
136
|
+
Behaviour follows OpenSpec conventions: a **requirement** (`### Requirement:`, RFC-2119 SHALL/MUST) with **scenarios** (`#### Scenario:`, Given/When/Then). Scenarios are the acceptance criteria and the source for tests.
|
|
137
|
+
|
|
138
|
+
- **Living spec** — `services/<svc>/spec.md` (+ `landscape.likec4`): the complete current state — the "final spec of the whole product".
|
|
139
|
+
- **Delta** — `features/<FEAT>/specs/<svc>/spec.md` (+ `delta.likec4`): a change, reviewed as a diff, tagged to the feature.
|
|
140
|
+
- **`loam archive <FEAT>`** merges the delta into the living state on three axes — **requirements** (`spec.md`, and `arch.spec.md` beside it: the two files ride one merge code path, parameterized by filename), **API** (`openapi.yaml`), **architecture** (`landscape.likec4`) — then archives the feature, so the living state stays complete. Archived deltas are the evolution history (like `git log`). `--dry-run` prints the whole plan and writes nothing.
|
|
141
|
+
- **Coherence gate:** `loam validate --feature` checks the three axes agree (C4 edge `op` ↔ OpenAPI `operationId` ↔ requirement `Operations:`). `loam archive` **blocks on the gating issues**. Severity and gating answer two different questions — severity says whether the *document* is valid (`validate` fails on errors), gating says whether the *merge* is safe — and they usually agree: errors gate, warnings do not. Where they diverge, the finding says so (`gates` in `--json`); today that is exactly `delta.requirement-not-merged`, a warning (the shape is legal OpenSpec, so adopted repos keep a green `validate`) that gates (the merge would silently drop the requirement). Advisory warnings are printed with the plan and never block. `--approve` overrides the gating issues only, and names each one it overrode. An operation that is missing from the provider's OpenAPI but defined by another feature still in flight is graded down to a warning (`spec-api.op-pending` / `c4-api.op-pending`) naming that feature — archive it first — because the fix is ordering, not authoring.
|
|
142
|
+
- **`loam unarchive <FEAT>`** takes an archive back: it restores the living docs and re-opens the feature.
|
|
143
|
+
- **The requirement baseline.** A MODIFIED requirement carries its full new text rather than a diff, so the merge REPLACES the living requirement instead of folding into it — and two features rewriting one requirement destroy the loser's text entirely. `loam validate` names the other feature while both are in flight (`delta.modified-conflict`, warn), but that check is blind to the case a fleet actually produces: the first feature archives and stops being active, the second revalidates green, and its archive lands text written against a document that no longer exists — `+0 ~1 -0`, exit 0, undetectable afterwards (`unarchive` of the first then refuses `snapshot-stale`, and `--force` takes the second out with it). So a MODIFIED/REMOVED requirement pins what it was written against with a **`Based-On:`** body line under its `Requirement-ID:` — `requirementDigest` of the living requirement, the same 16-hex-character truncation `sources_digest` uses. It is written by **`loam rebase <FEAT> [--service <id>] [--dry-run]`**, never by hand: rebase takes the docs lock, computes each pin from the living text as it stands, and rewrites only that one line per requirement (line surgery, not reserialization — a delta document's sections, order and prose are authored content). The digest is taken over the requirement's *canonical serialization*, not its bytes, so archive's own reflowing of a living spec never makes a pin go stale falsely. `delta.baseline-stale` is an error and gates archive: there is no reading of a stale delta under which the merge is what its author meant. `delta.baseline-missing` is only a warning — deltas adopted from OpenSpec never carried the line, and refusing to archive a migrated corpus is not a safety property. `Based-On:` is stripped on the way into the living document: it is a claim about a delta, meaningless once it *is* the living text, and leaving it in would make the next feature's baseline hash the previous feature's bookkeeping. Re-pinning is the last step of resolving a collision and never the resolution itself — a pin says "I read this version", so `loam rebase` reports every requirement that moved and says plainly that restamping without re-reading merges your text over theirs. There is deliberately no three-way merge: requirement prose does not merge.
|
|
144
|
+
- **The same baseline on the contract axis, where it matters more.** A feature's `openapi.yaml` is a **complete document, not a patch** — authors restate the living contract around the slot they change — and the merge upserts every operation the document spells. The collision therefore needs no overlap between the two features at all: two deltas over one service, editing *different* operations, destroy each other, because whichever archives second pushes its authoring-time copy of the operation it never meant to touch back over what landed in between. On a service with thirty operations a delta quotes twenty-nine of them, and each is a candidate. So every operation in a feature contract carries **`x-loam-based-on`** — the same 16-hex digest, in the vendor-extension shape this axis already uses for `x-loam-remove`, written by `loam rebase` and stripped on merge exactly like `Based-On:`. The digest is taken over the operation's *canonical* form (object keys sorted), which is the same comparison `isDeepStrictEqual` makes when the merge decides an operation "differs" — so a pin goes stale exactly when the merge would say something moved, never over a reordered key. The pin is always the digest of the **living** operation, and that one rule yields both merge verdicts by itself: an operation the author only quoted is byte-equal to living, so its pin equals its own content and **the merge skips it entirely** (reported as `· quotes …` in the plan, so the plan never silently writes less than the delta spells); one the author edited differs from its pin, so the merge writes it — unless the living operation has also moved, which is `openapi.baseline-stale` (error, gates). Skipping a quote is mechanical rather than advisory, so `--approve` does not turn it back on: overriding a gate is a decision, reverting an operation nobody edited is a bug. `openapi.baseline-missing` is a warning and is reported **once per service** rather than per operation — the fix is one command, and twenty-nine identical findings teach people to filter the code out. **Not covered, by decision:** path-level keys (`parameters`, `servers`) and `components/schemas` are still upserted wholesale, so a restated schema can still revert another feature's change to it — `openapi.path-item-modified` and `openapi.component-modified` remain the only signal there; and removal markers carry no pin, since `openapi.remove-target-mismatch` already checks that exact slot.
|
|
145
|
+
|
|
146
|
+
A requirement may carry a stable identity in its body: `Requirement-ID: <id>`, where the ID matches `[A-Za-z][A-Za-z0-9._-]{0,127}`. It is optional for backward compatibility. When present in a delta, archive matches the living requirement by ID; a `MODIFIED` block may therefore change the `### Requirement:` heading without losing history or verification evidence. When absent, the exact heading remains the legacy identity. A legacy `MODIFIED` that matches an ID-bearing living requirement by name inherits that ID, which supports incremental adoption. `Requirement-ID` may appear only once per requirement and must be unique within each `spec.md` / `arch.spec.md`; invalid, repeated, duplicate, or ID-versus-heading collisions are errors, and the merge refuses to guess. This is loam's explicit rename mechanism: stable ID plus `MODIFIED`, not OpenSpec's `RENAMED` section.
|
|
147
|
+
|
|
148
|
+
Two `### Requirement:` blocks with one name in one LIVING document are still an **error** (`spec.duplicate-requirement`, per file — `spec.md` and `arch.spec.md` are separate namespaces, so one name in both is legal): a legacy name-based merge cannot choose safely. A repeated `Operations:` or `Covers:` line inside one requirement body is a **warning** (`spec.repeated-operations` / `spec.repeated-covers`, living specs and feature deltas alike): the last line REPLACES the earlier ones — assignment, not append, the documented keep-last quirk, whose semantics deliberately do not change — so the author's "long list in two lines" pattern silently loses its first line; merge them into one comma-separated line.
|
|
149
|
+
|
|
150
|
+
### The architecture spec axis (`arch.spec.md`)
|
|
151
|
+
|
|
152
|
+
The business spec will never mention the transactional outbox — that is architecture. Retries, idempotency, metrics, alerts: real obligations, invisible to every business scenario, and exactly where agent-generated code cuts corners unless the obligations are derived mechanically. So they get their own spec file, in the same grammar:
|
|
153
|
+
|
|
154
|
+
- **Living** — `services/<svc>/arch.spec.md`: the architectural requirements as they stand, under `## Requirements`.
|
|
155
|
+
- **Delta** — `features/<FEAT>/specs/<svc>/arch.spec.md`: the change, under the same `## ADDED|MODIFIED|REMOVED Requirements` algebra.
|
|
156
|
+
|
|
157
|
+
Frontmatter follows `spec.md`'s conventions exactly (`service`, `status`, `sources` — the same provenance pass reads both, only the label differs), and `loam vouch` stamps the pair in one all-or-nothing run when this file is present. **Absence is not a finding**: partial adoption is supported, and the obligations below fire only when there is something to cover.
|
|
158
|
+
|
|
159
|
+
**The `Covers:` line** is the architecture analog of `Operations:` — where a business requirement declares the operations it governs, an architecture requirement declares the model objects its scenarios exercise. Comma-separated, same keep-last-line quirk (and the same repeated-line warning, `spec.repeated-covers`), three entry forms:
|
|
160
|
+
|
|
161
|
+
- a **C4 element** — its id (`paymentService.db`), or the service a bound/titled element stands for; resolved against the service's own model plus the landscape (for a feature delta: the feature's `delta.likec4`, the landscape, and the service's model);
|
|
162
|
+
- an **edge** — `paymentService -> kafka`, each side resolved the same way against the declared relationships;
|
|
163
|
+
- a **health signal** — `alert:<id>` / `sli:<id>`, ids the service's `health.yaml` declares.
|
|
164
|
+
|
|
165
|
+
An entry that resolves to nothing is `covers.unknown` (**warning**, with close ids offered where they exist): the typo guard, because a mistyped entry silently costs exactly the coverage it was written for.
|
|
166
|
+
|
|
167
|
+
**Coverage obligations** — both warnings, deliberately: they never gate `archive`, and `--strict` is the CI escalation.
|
|
168
|
+
|
|
169
|
+
- `c4.uncovered` (feature scope): a NEW tagged element or tagged edge in the feature's `delta.likec4` that no requirement across the feature's `arch.spec.md` deltas covers. Grouping-only elements follow the landscape checks' exemptions (`person`-kind elements, `#external` tags).
|
|
170
|
+
- `health.uncovered` (service scope): an alert or SLI declared in `services/<svc>/health.yaml` that no requirement in the LIVING `arch.spec.md` covers. This is the moment `health.yaml` stops being inert — and all loam reads out of it is ids: the recognized keys are top-level `slis:` and `alerts:`, each a sequence whose entries contribute their `name` (or `id` when there is no name; a plain string entry is its own id). A `health.yaml` that is missing, or declares nothing recognizable, yields no findings — an absent file must not manufacture obligations. One that EXISTS but does not parse is different evidence and gets its own **warning** (`health.invalid`): its ids are unknown, not empty, so `health.uncovered` stays quiet AND the `Covers: alert:/sli:` entries of that service go ungraded — the old silence turned every such entry into a false `covers.unknown` "typo" pointing at the spec, when the truth was the file.
|
|
171
|
+
|
|
172
|
+
The rest of the machinery treats the axis as what it is — requirements. `loam archive` merges an `arch.spec.md` delta into the living one through the same code path as `spec.md` (same delta algebra, same prose-preserving rewrite, same guards and delta-shape checks — the two files are separate requirement namespaces, so an arch requirement never collides with a business one of the same name); the snapshot covers it and `unarchive` restores it. `loam verify` derives `scenario.tested` claims from ADDED/MODIFIED arch requirements exactly as from business ones — the claim id, the claim text AND the scenario digest carry `arch.spec.md` (the arch axis salts the body hash), so an identically-worded scenario in both files stays two questions with two digests, and the answering agent knows an integration/ops test is being asked for. `loam delta --json` projects arch requirement deltas as `archRequirements`, the same item shape as `requirements` (each item also carries `covers`).
|
|
173
|
+
|
|
174
|
+
**Test levels, mapped once:** a business scenario is an acceptance test — emitted as a generated `.feature` by `loam gherkin`; an arch scenario is an integration or operational test (the outbox relay, the retry, the alert rule) — the same emission, tagged `@architecture`; `api.exposes` is a contract test; unit tests sit below spec granularity and stay the coding agent's TDD concern.
|
|
175
|
+
|
|
176
|
+
### The generated Gherkin suite (`loam gherkin`)
|
|
177
|
+
|
|
178
|
+
Scenarios are the source for tests, and `loam gherkin` is where that stops being prose: it emits them as real `.feature` files into the SERVICE'S repo — the only loam command that writes there, because tests live with the code they gate. The output root is `<gherkinDir>/loam/`, where `gherkinDir` is an optional `loam.json` fact (default `features`, the cucumber convention; a non-string refuses the config like a malformed `docsDir`). The `loam/` subdirectory is loam's own derived space, and the ownership rule is absolute both ways: regenerating a scope rewrites that scope's files and deletes its orphans (reported), and loam never writes or deletes a byte outside `loam/` — step definitions and hand-written features live outside it, untouched. An emission with nothing to emit creates nothing: an empty `loam/` would read as a whole suite gone missing.
|
|
179
|
+
|
|
180
|
+
Two scopes, one emitter, run inside the service's repo (anywhere else refuses — vouch's discipline, because the files land where loam is standing):
|
|
181
|
+
|
|
182
|
+
- **Feature mode** — `loam gherkin <FEAT> [--service <id>]`: the feature's ADDED and MODIFIED requirements for that service, both spec axes, each file tagged with the feature id.
|
|
183
|
+
- **Living mode** — `loam gherkin --service <id>`: the full acceptance suite from the living `spec.md` + `arch.spec.md` — the regression skeleton a legacy service gets at adoption.
|
|
184
|
+
|
|
185
|
+
**The mapping is deterministic** — same specs, same bytes, which is what lets staleness be judged by digest:
|
|
186
|
+
|
|
187
|
+
- one `.feature` per requirement: `Feature:` is the requirement name, the requirement's body text is the feature description, its scenarios are `Scenario:` blocks;
|
|
188
|
+
- file naming: `<slug>.feature` (business axis) / `arch--<slug>.feature` (arch axis) — flat and diffable. Slugs lowercase the name and collapse every non-alphanumeric run to one hyphen, so no business slug can ever begin `arch--`; requirements slugging identically are numbered in document order (`retry.feature`, `retry-2.feature`);
|
|
189
|
+
- steps: a scenario body line that is a list bullet (`-`, `*`, `+`) whose text — after stripping the marker and a `**bold**` wrapper around the keyword, trailing colon tolerated — opens with Given/When/Then/And/But (case-insensitive, so the OpenSpec `- **WHEN** ...` convention counts) becomes that keyword's step. Every other non-blank body line is kept, edge-trimmed, as the scenario's description, rendered before the steps (Gherkin ends a description at the first step keyword) — prose is never dropped. A scenario that yields ZERO steps (numbered-step or prose-only legacy bodies) still emits, but the emission says so per scenario (text, and `stepless` per file under `--json`): cucumber runs a step-less scenario vacuously green while `verify --results` requires at least one passed step, so it is permanently unconfirmable until the spec's bullets are reworded;
|
|
190
|
+
- tags, on the `Feature:` line and inherited by its scenarios: `@<FEAT>` in feature mode (living emissions carry no feature tag), `@architecture` on the arch axis — the test-level marker;
|
|
191
|
+
- stamps: line 1 is `# generated by loam v<version> — …` (the AGENTS.md stamp pattern — the file is never to be hand-edited), and each scenario carries the tag `@loam-digest-<16hex>` on the line above its `Scenario:` keyword: the first 16 hex characters of `loam verify`'s scenario body hash — sha256 of the body lines joined and edge-trimmed, salted with the file name on the arch axis, the exact recipe `scenario.tested` claim ids fold in — so the stamp, the claim and the spec can never disagree about what a scenario says, and identically-worded scenarios across `spec.md` and `arch.spec.md` can never share a digest (a business-axis test run cannot answer for an arch scenario). A tag rather than a comment because cucumber's JSON report carries tags per scenario (`elements[].tags`): the stamp rides through the runner untouched, which is what `loam verify --results` matches on.
|
|
192
|
+
|
|
193
|
+
**Staleness** (`loam validate --service <id>`, service-repo-scoped like the `sources.*` chain): fires only where the repo is known AND `<gherkinDir>/loam/` exists — a service that never generated has not opted in, and stays quiet. The living specs are the reference; digests decide content, requirement names decide identity, and every comparison is axis-scoped (the two spec files are two namespaces: an arch scenario's integration test is not answered by a business `.feature` spelling the same words). Three warnings — never gates, `--strict` is the CI escalation — plus `gherkin.current` (ok) when a suite exists and agrees:
|
|
194
|
+
|
|
195
|
+
- `gherkin.missing` — a living scenario whose digest no stamped scenario carries: the suite has no test for these words;
|
|
196
|
+
- `gherkin.stale` — a stamped scenario whose digest matches no living scenario while its file's requirement still exists: the spec moved under the file. A reworded scenario reports stale + missing together, and one regeneration clears both;
|
|
197
|
+
- `gherkin.orphaned` — a file whose requirement no longer exists in its axis's living spec, reported once per file: every scenario in it is moot together.
|
|
198
|
+
|
|
199
|
+
A digest is content identity within an axis, deliberately: two identically-worded scenarios of one axis share one, and one stamped copy covers both. A file tagged with a feature still **in flight** is exempt from stale/orphan grading — it answers to its feature's delta (`loam gherkin <FEAT>` is its regeneration) until the feature archives, at which point its requirements ARE living requirements, digests unchanged, and the same bytes grade current with no rewrite. The exemption guards the writer too: a living-mode `loam gherkin` neither deletes NOR overwrites an in-flight file, even when a MODIFIED requirement's living emission collides with its filename — the file is reported as kept (`action: "kept"` under `--json`), because replacing it would silently revert the delta's wording mid-flight. An abandoned feature's tag names nothing active, so its files fall to `gherkin.orphaned` and regeneration removes them. Mid-flight, a MODIFIED requirement's reworded living scenarios do report `gherkin.missing` — the suite tests the delta's words while the living spec still promises the old ones; that is true, and archiving the feature clears it.
|
|
200
|
+
|
|
201
|
+
The loop is closed end to end: generate → write step definitions (outside `loam/`) → run the suite with a JSON report — `cucumber-js --format json:report.json`, the CI recipe; cucumber-jvm, behave and SpecFlow emit the same format — → implement until green → `loam verify <FEAT> --service <id> --results report.json [--record rest.json]`, run in that service's own repo. The digest tags ride through the runner into the report, so the done-check's `scenario.tested` claims are answered by the green run itself; see "The verification record".
|
|
202
|
+
|
|
203
|
+
### The smallest legal feature
|
|
204
|
+
|
|
205
|
+
A one-service bugfix needs exactly one file: `features/<FEAT>/specs/<svc>/spec.md` holding one `MODIFIED` requirement with its scenarios. That floor is verified against the checks, not asserted: with no `intent.md`, no `delta.likec4` and no openapi delta, `loam validate --feature` passes with zero errors and zero warnings, and `loam archive` plans exactly two writes — the living spec update and the move into `features/archive/`.
|
|
206
|
+
|
|
207
|
+
Each axis may be legitimately empty, and the checks grade absence differently from emptiness-by-accident:
|
|
208
|
+
|
|
209
|
+
- **`delta.likec4`** — absent, or present with an empty `model {}` (both verified: `delta.valid`, no landscape write in the plan): a behaviour fix moves no boxes and no edges, and the architecture merge simply has nothing to do. The line is drawn at content: a file that *declares* elements or relationships with none carrying the feature tag is an error (`delta.nothing-tagged`), because declared-but-untagged is almost always a forgotten tag, while declaring nothing is a legible statement that the architecture is unchanged.
|
|
210
|
+
- **openapi delta** — absent whenever the feature adds no operations; a requirement change that stays inside the existing contract has no API axis to speak on.
|
|
211
|
+
- **requirement delta** — the axis that is nearly always present, because a feature that changes no requirement changes no promised behaviour. The exception is architecture-only work: a new service in the C4 delta with no `specs/<svc>/spec.md` gets `service.no-requirement-delta` as a warning, not an error.
|
|
212
|
+
|
|
213
|
+
`intent.md` is required by no check today — an absent one produces no finding at all — but it is where the "why" lives, and `loam new` scaffolds it for a reason: the delta says what changed, and nothing else says what for.
|
|
214
|
+
|
|
215
|
+
### Where a capability lives (and why there is no capability layer)
|
|
216
|
+
|
|
217
|
+
A business capability — "payment splitting" — is spread across the living specs of every service that carries part of it. The obvious fix is a `capabilities/` layer holding the whole story in one place. **There is none, and there should not be one.**
|
|
218
|
+
|
|
219
|
+
The story is already whole and already derivable. Every requirement in a living spec arrived through exactly one feature, and that feature is still on disk under `features/archive/` with its intent, its C4 delta and its requirement deltas intact. "Which feature introduced this requirement" is a search, not a record — so a capabilities layer would be a second copy of text that already exists, which is a second thing that can disagree with the first. That is the same reason there is no service manifest.
|
|
220
|
+
|
|
221
|
+
A hand-written `capability:` label on a feature was considered and rejected for a sharper reason: **nothing could check it.** `sources` is hand-written too, but it is checkable — the paths exist or they do not, the digest matches or it does not. There is no ground truth for what counts as a capability, so the field would end up on some features and not others, in three spellings of the same theme, never revisited — while creating the impression that an index exists.
|
|
222
|
+
|
|
223
|
+
For a theme that genuinely crosses services and matches no structural unit, the mechanism already exists and is checkable: **a LikeC4 tag**. Tags are declared in a `specification` block, so a misspelling is a parse error rather than quiet drift; `validate` already reads them, `archive` already handles them, and they show up in the diagram.
|
|
224
|
+
|
|
225
|
+
### Considered and rejected: the rest of the OpenSpec feature set
|
|
226
|
+
|
|
227
|
+
loam takes OpenSpec's requirement format and leaves most of its machinery, deliberately. OpenSpec and loam optimize different units: a capability-oriented repository versus a contract-linked service fleet. The points below describe the actual v1.7 concepts rather than treating every upstream feature as a workaround for not having a model.
|
|
228
|
+
|
|
229
|
+
- **Workflow schema / DAG engine** — OpenSpec derives artifact completion from whether each schema-declared output exists; it is not a separate mutable workflow-state database. loam still declines arbitrary project graphs because its fleet checks depend on one versioned lifecycle and one meaning of ready. The trade-off is lower workflow customizability, not duplicate state.
|
|
230
|
+
- **Stores** — OpenSpec Stores are standalone Git planning roots with explicit root selection, optional project pointers and read-only references to other Stores. They solve shared planning ownership, not caching. loam keeps one docs root because it also owns the fleet topology; an external reference index should be reconsidered if several independent fleet-doc roots become common.
|
|
231
|
+
- **Worksets** — upstream Worksets are personal sets of folders/editor state reopened together, not hand-curated evidence bundles consumed by validation. loam has no editor-session manager; `loam delta <FEAT> --service <id>` separately derives a model-scoped work view.
|
|
232
|
+
- **Profiles** — upstream profiles select which workflow commands are installed for a user; project artifact behavior comes from config/schema. loam keeps a smaller fixed command surface. The invariant to protect is fleet validation semantics, not a claim that an OpenSpec profile itself changes them per repository.
|
|
233
|
+
- **TUI** — agents and CI get `--json`; humans get the files and the forge. A third surface would be a third thing to keep truthful.
|
|
234
|
+
- **Authored `tasks.md` as authoritative state** — loam derives its actionable checklist (`loam delta`, `loam verify`), so it cannot drift from the delta. During migration, however, upstream tasks may still contain implementation order and progress, especially for `skip_specs: true` changes; the audit requires an explicit disposition and preserves them as non-authoritative legacy checklists until reviewed.
|
|
235
|
+
- **Per-tool adapter matrix** — mostly. `AGENTS.md` travels with the docs as the one tool-agnostic contract, so any runner that can read a file and exec a binary is already supported. `loam init --tools` does emit the slash commands for several tools, but each adapter is only a path plus a format wrapper around the one shared body — never per-tool prose, profiles, delivery modes, or a managed marker block in someone else's file, and like everything init writes, never overwritten once on disk.
|
|
236
|
+
|
|
237
|
+
The compatibility stance follows the same one-way logic: **loam reads OpenSpec, never writes it.** Serializing drops `## Purpose` and the `## Requirements` wrapper, both of which OpenSpec's parser requires — so loam output is not OpenSpec input, and pretending otherwise would corrupt someone's repo politely. [MIGRATING-from-OpenSpec.md](MIGRATING-from-OpenSpec.md) has the full account, with the evidence pinned in `test/openspec-compat.test.ts`.
|
|
238
|
+
|
|
239
|
+
### How archive writes, and how unarchive undoes it
|
|
240
|
+
|
|
241
|
+
The merge is computed in full before anything is written, and then committed file by file: each new version is staged as a temp file **in the target's own directory** and renamed into place, so a reader sees either the old bytes or the new ones and never a half-written document. If any file fails — including the final move into `features/archive/` — the files already swapped are put back from the bytes read before the swap, and the command says so.
|
|
242
|
+
|
|
243
|
+
**The whole path moves bytes, not strings.** Content is encoded once when a write is staged, the compare-and-set before each rename compares bytes, and the snapshot pre-image is written unmodified; text is produced only where a parser needs it. A file loam would have to parse as text and cannot decode as UTF-8 refuses the merge (`merge-failed`, naming the file) rather than being written back with every undecodable byte replaced by U+FFFD — which is what used to happen, to the living document *and* to the snapshot meant to undo it, leaving nothing to restore from. PowerShell's `>` defaulting to UTF-16 is a realistic way to reach this.
|
|
244
|
+
|
|
245
|
+
**A process killed between two renames is recoverable.** Before the first swap, `archive`/`unarchive` write `.loam-commit` at the docs-repo root — an intent record naming the command, the feature, the host and pid, the final move, and a before/after digest per path — and fsync it (and its directory where the platform allows). It is deliberately not a source of truth and never disagrees with the files: every judgement it supports is made by re-reading the living bytes and comparing digests, and every byte a repair writes comes from the snapshot and is verified against the digest captured before the crash. The next `archive`/`unarchive` recovers from it first, under the lock: `completed` when the final move landed, `consistent` when no file was left in the other state, `repaired` when files were put back, and `commit-interrupted` (a refusal, exit 1) when a half-written file now holds bytes that are neither state or the repairing pre-image is gone or altered. An archive is **undone**; an unarchive is **finished**, because the merged text it was replacing is written down nowhere else. The record is removed after the final rename and on every rollback path, so outside the commit window it does not exist. `.loam-commit` and `.loam-lock` are both transient writer state and both belong in a docs-repo `.gitignore`; `loam doctor` grades them (`doctor.commit-interrupted`, `doctor.commit-unreadable`, `doctor.docs-locked`, `doctor.staging-temps`) instead of reporting a half-merged repo as healthy.
|
|
246
|
+
|
|
247
|
+
**One writer at a time, and only over the bytes it read.** `archive` and `unarchive` hold an advisory lock on the docs repo (`.loam-lock`, created `O_EXCL`, released in a `finally`) across the whole plan-and-commit; a second one refuses with `docs-busy` having read and written nothing, and re-running once the first finishes works. A lock naming a process that is no longer alive on this host is broken once and retried, so a crash cannot wedge the repo. Inside that lock the swap is still guarded per file: each target is re-read immediately before its rename and compared with the pre-image the plan was computed from, and a mismatch fails the commit (`merge-failed`) rather than overwriting an edit that landed in between. Rollback is guarded the same way — a file whose current bytes are not the ones the merge wrote is not restored over, and is named in the failures instead, which correctly promotes the answer to `rollback-incomplete`. Cleanup after a failed create prunes only the directories it created and only while they are empty (`rmdir` up to the first `ENOTEMPTY`), never a recursive delete: the directory that would have been removed is a directory somebody else may have just put a file into.
|
|
248
|
+
|
|
249
|
+
Undoing that merge is not a matter of inverting it. **A `MODIFIED` requirement's previous text is recorded nowhere** — the delta says what the requirement became, never what it was — and the landscape merge drops the feature tags, so the lines it added stop being identifiable the moment they land. Anything reconstructed would be a plausible guess at the old docs, which is the kind of quiet fiction the rest of loam exists to prevent.
|
|
250
|
+
|
|
251
|
+
So archive writes the bytes down. Before it swaps anything it copies every file it is about to overwrite into `<feature>/.loam-before/`, with a `manifest.json` naming each one, whether it existed at all (a file the merge *created* is restored by deleting it), and **two** hashes: `after`, of what the merge wrote, and `before`, of the pre-image sitting beside the manifest (null when archive created the file). That directory travels with the feature into `features/archive/`, and `loam unarchive` puts it back.
|
|
252
|
+
|
|
253
|
+
The manifest is **version 2**, and `before` is what the bump is for. A version-1 snapshot described only what archive *wrote*; nothing described the bytes it would *restore*, so a pre-image altered inside the archived feature directory — a bad merge, a rebase, a stray editor — was written back verbatim and every loam surface certified the result as "the living docs are back to what they said before the archive". `unarchive` now hashes every pre-image before staging anything and refuses on a mismatch (`snapshot-corrupt`), and a version-1 snapshot is refused as `snapshot-missing` with the answer being version control. Correspondingly, `archive` refuses to replace a `.loam-before/` whose pre-images no longer match the living bytes: the previous archive's writes are still in the docs and those files are the only way back.
|
|
254
|
+
|
|
255
|
+
Some checks run at plan time rather than in the gate, because only the computed merge can see them. A **living** requirement sitting outside `## Requirements` refuses the whole archive (`living.requirement-outside-requirements`, error): the merge rewrites only that section, so the requirement would land in the file twice — and `--approve` does not override it, because the duplication is mechanical, not a judgment call; re-home the requirement first. A delta that redefines a path+method the living OpenAPI already has (`openapi.op-modified`, warning) merges anyway, but the plan says so — the overwrite is wholesale, and the living definition it replaces deserved an eye. An unparseable `delta.likec4` fails the archive at plan time regardless of `--approve`: the alternative was archiving with the architecture axis silently dropped, which is the data loss this tool exists to prevent.
|
|
256
|
+
|
|
257
|
+
The landscape merge **splices authored source, it does not re-serialize**. Each new element and relationship is copied into the living landscape byte-for-byte from `delta.likec4` — technology, style, icons, links, metadata, nested children, every tag — with exactly one edit: the feature's own tag is stripped (the additions are baseline now — the drop above is why unarchive restores bytes), and a construct the strip empties goes with it (`x = kind 'y' { #FEAT-1 }` lands as `x = kind 'y'`). Placement is deterministic and **service-grouped**, and never moves an authored byte — it only chooses where the new blocks go. A top-level element whose service the model already draws (the `metadata { service }` binding first, the title as fallback) lands immediately after the last element resolving to that service — it joins its service's neighborhood; an element opening a new service lands in the trailing region before the model's closing brace, sorted among its fellow no-neighborhood insertions. A relationship lands after the last top-level relationship touching its source's service (either endpoint counts), ordered by a stable key past a shared anchor, with the same sorted trailing region as the fallback. The unit of that sort is the **neighborhood, not the statement**: a contiguous same-service run of elements — or the cluster of relationships grown around the statements touching one service — moves through the trailing walks as a block, keyed by its leading statement (an anchored join sits where its anchor is, wherever its own id or key would sort, so comparing per statement would make placement depend on which archive ran first). That is what makes the scheme **order-independent**: archiving feature A then feature B (touching different services) yields the byte-identical landscape to B then A, so concurrent archive PRs usually splice different lines and git merges them without help. The insertions carry no marker comment — which archive added what is the git history's to say. A **nested** element lands inside its parent's block — inside the living parent when the landscape already has it, riding verbatim inside the spliced parent when the parent is new — never as a flat dotted id, which LikeC4 rejects at top level. LikeC4 reference resolution is not order-dependent, so a relationship placed before an element it names still parses — and because splicing is text surgery, the computed landscape is **parsed in memory before anything is written**: a result LikeC4 rejects — a splice bug, or legal inputs the living document cannot absorb, like an element kind or tag its `specification` block never declares — refuses the archive at plan time (`merge-failed`, nothing written), the same discipline as the unparseable delta. A living declaration sharing its line with another statement (legal LikeC4 the splice map cannot see around — its statement scan runs to the end of the line) refuses the same way: give each top-level declaration its own line, then re-run.
|
|
258
|
+
|
|
259
|
+
The OpenAPI merge carries components along with operations: every `#/components/<kind>/<name>` the merged path items reference — recursively, a component's own `$ref`s pulling in more — is copied from the feature document into the living one, so an operation never lands pointing at a schema that stayed behind. A needed component the living document already holds **identically** is left alone; one it holds **differently** is overwritten wholesale under the same discipline as an operation (`openapi.component-modified`, warning — the merge proceeds, the plan says so). A `$ref` reachable from the merged content that resolves in **neither** document blocks the archive (`openapi.ref-unresolved`, error): merging it would write a dangling reference. `--approve` overrides it — unlike the mechanical refusals, this one is a judgment call. External references — URLs, file paths, anything not starting `#/` — are out of scope: left untouched, never gated.
|
|
260
|
+
|
|
261
|
+
`loam archive --json` speaks the same machine contract as everything else: on success (or `--dry-run`) the plan (`{path, action, to?}` per file), the non-blocking warnings, and whatever `--approve` overrode; on refusal a stable `error.code` — `not-coherent` and `living-outside-requirements` carry the issues, `archive-exists` names a taken destination, `docs-busy` means another archive or unarchive holds the lock and nothing was touched, `merge-failed` means nothing was written (or everything was rolled back), `rollback-incomplete` lists the files that need a hand.
|
|
262
|
+
|
|
263
|
+
`unarchive` refuses rather than guesses, each refusal under its own `--json` `error.code`:
|
|
264
|
+
|
|
265
|
+
- `feature-active` — a feature of that id is active again; restoring over it would bury work in flight;
|
|
266
|
+
- `snapshot-missing` — archived before snapshots existed, or by an older layout version (a version-1 manifest, which records nothing about its restore source); the living docs have to come back from version control instead;
|
|
267
|
+
- `snapshot-stale` — a merged file changed after the archive, so this would be a revert of someone else's work rather than an undo. `--force` says that was meant. The test is "the file holds neither what archive wrote nor what this restore would write", so a file already holding its own pre-image — what an interrupted restore leaves behind — is not mistaken for a later change;
|
|
268
|
+
- `snapshot-corrupt` — a pre-image no longer hashes to the `before` digest archive recorded for it, so restoring it would write text nobody authored. **`--force` does not override this**, and the difference is the point: `--force` discards *later* changes to the living docs, while the damage here is to the undo itself;
|
|
269
|
+
- `commit-interrupted` — a previous `archive`/`unarchive` was killed mid-commit and this run cannot repair it on its own.
|
|
270
|
+
|
|
271
|
+
Rules (`loam validate`): every requirement has ≥1 scenario; every C4 edge `op` resolves to an OpenAPI operation governed by a requirement; and **the diff applies** — a `MODIFIED`/`REMOVED` requirement exists in the living spec, an `ADDED` one does not, and a section heading matches `## ADDED|MODIFIED|REMOVED Requirements` exactly. A near-miss heading (`## ADDED Requirement`, singular) parses as prose, so without this check archive merges nothing and reports nothing. `## RENAMED Requirements` — OpenSpec's fourth delta operation — gets the same error (`delta.unknown-section`): loam's rename is a stable `Requirement-ID` carried through a `MODIFIED` requirement (legacy documents can still use `REMOVED` + `ADDED`). And an `ADDED` legacy name that differs from a living requirement's only in case is a warning (`delta.added-near-duplicate`): without a stable ID, both spellings would coexist in the living spec.
|
|
272
|
+
|
|
273
|
+
**The section heading is what gives a requirement its kind.** A requirement under any other H2 (`## Behavior`, `## Error Handling` — a historically accepted legacy OpenSpec "complete future state" shape) has no kind, and archive merges nothing for it; `delta.requirement-not-merged` names each one and the heading that stranded it — a warning for adopted history that **gates archive** (authored content must not vanish silently; `--approve` overrides). OpenSpec v1.7's default spec-driven schema requires a real delta unless `.openspec.yaml` explicitly uses `skip_specs: true`; migration applies that zero-spec opt-out only from a valid marker whose named built-in or direct project schema resolves, never merely because a custom artifact graph omits specs. Legacy archive presence is not proof that the shape is currently valid. When *no* requirement in the file sits under a delta section — prose headings only, requirements above every heading, or a file that only quotes the living state — the grade goes up to an **error**, `delta.no-delta-sections`: a delta that would merge nothing as a whole is not a valid delta. `## Requirements` stays exempt from the per-requirement warning: quoting the living state inside a delta is legal and merges nothing by design. The heading must be reachable, too — a leading UTF-8 BOM used to hide `## MODIFIED Requirements` on line 1 and void the entire delta, so the parser strips one.
|
|
274
|
+
|
|
275
|
+
## The verification record
|
|
276
|
+
|
|
277
|
+
`features/<FEAT>/verification.yaml` is the done-check written down. `loam verify <FEAT>` derives a **checklist** from the feature's own artifacts — the same files `validate` reads, never the code:
|
|
278
|
+
|
|
279
|
+
| kind | one claim per | reads |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| `service.exists` | tagged top-level element the delta introduces | `delta.likec4` |
|
|
282
|
+
| `api.exposes` | operationId the feature's openapi delta adds that the living one lacks | `specs/<svc>/openapi.yaml` |
|
|
283
|
+
| `c4.calls` | tagged edge carrying `metadata { op }` | `delta.likec4` |
|
|
284
|
+
| `scenario.tested` | scenario of every ADDED/MODIFIED requirement | `specs/<svc>/spec.md` + `specs/<svc>/arch.spec.md` |
|
|
285
|
+
|
|
286
|
+
Each claim's **id** is `<kind>-<8 hex>`, hashed from the feature id and what the claim says (with an occurrence counter for genuine duplicates). For `scenario.tested` "what the claim says" includes the requirement's stable `Requirement-ID` when present (otherwise its legacy heading), plus a hash of the scenario's **body**, not just its title. So a heading-only rename under one stable ID preserves its evidence, while the same feature otherwise yields the same ids on every run — two runs are diffable — reordering the artifacts renames nothing, and **rewording a scenario renames its claim, even a Given/When/Then rewrite under an unchanged title**, which is the point: an answer about text nobody wrote must not carry over as if it still applied.
|
|
287
|
+
|
|
288
|
+
`loam verify <FEAT> --service <id> --record <answers.json>` takes the answers back and refuses anything that does not answer the *current* checklist, each under its own `--json` `error.code`: `answers-unreadable` (not JSON, or a verdict outside `confirmed` / `unconfirmed`), `answers-mismatch` (an id nobody asked about, a claim with no answer, or one answered twice), `answers-unevidenced` (a `confirmed` with no `file:line` behind it). An unchecked claim must never be able to masquerade as checked.
|
|
289
|
+
|
|
290
|
+
**`--results <report.json>` answers the `scenario.tested` claims mechanically, from a cucumber JSON test report** — the format `cucumber-js --format json` emits, and cucumber-jvm, behave and SpecFlow speak too. The contract is exactly this and nothing more: a top-level array of features, each `elements[]` (scenarios) with `name`, `tags[] {name}` and `steps[] {result {status}}` — plus the two places those dialects put a failure the steps never see: cucumber-jvm's per-element `before[]`/`after[]` hook results and behave's element-level `status`. Every other field is ignored, tagless elements (backgrounds, hand-written scenarios) are invisible, and a file that is not that array at all refuses under `answers-unreadable` rather than quietly answering every claim "not found".
|
|
291
|
+
|
|
292
|
+
Matching is by digest and nothing else: a claim is answered by the report scenarios carrying its `@loam-digest-<16hex>` tag — the tag `loam gherkin` stamped, the same 16 hex of the same body hash the claim id folds in; the axis rides in that hash (the arch recipe salts the body with its file name), so a business run can never answer an arch claim however identically the two scenarios are worded. Names never match anything: a reworded spec scenario matches nothing until the suite is regenerated and re-run — an agent must not be able to SAY a scenario is tested; only a green run may. The verdicts: every matching occurrence (a digest the report holds twice is a re-run, and all occurrences count) ran at least one step, every step `passed`, every before/after hook `passed`, and the element-level status (when the dialect carries one) is `passed` → `confirmed`, with evidence `<report-path>: <feature uri or name> › <scenario name>` per occurrence; a failed / undefined / pending / ambiguous step (`failed at step N`), a failing hook (`failed in a before/after hook`), a failed element status, a skipped-only run (`skipped`), or no match at all (`not found in report`) → `unconfirmed` with the reason as the note.
|
|
293
|
+
|
|
294
|
+
`--results` OWNS every `scenario.tested` claim **in the same run**: an answers-file entry for one is refused (`answers-mismatch` — the runner owns it), `--record` alongside must answer exactly the non-scenario claims, and `--results` alone is legal only when the checklist is all scenarios (otherwise the refusal lists the unanswered ids). On an archived feature `--results` refuses exactly as `--record` does — frozen history.
|
|
295
|
+
|
|
296
|
+
The record also writes down **which report it read**: a `report:` block with the file's path, the sha256 of its bytes, its mtime and the count of digest-tagged scenarios — on the record for the all-at-once form, and filed with that repository's attestation in the federated form, so it is pruned with its answers. In federated mode the report must resolve *inside* the attesting repository, the same rule evidence paths obey, and a report that IS committed there and differs from the attested commit is refused by name. Be precise about what that proves: it identifies which file was consumed and, when the file is committed, that it matches the attested commit. It does not prove the file came from executing that commit. No digest can.
|
|
297
|
+
|
|
298
|
+
**Two services wording a scenario identically share one digest**, and a single report then cannot say whose suite ran it. Those claims are left `unconfirmed` with `verify.digest-contested` (warn) naming both services and the affected claim ids, rather than confirming both from one run. Record each service's claims from its own repository with `--service`, which narrows the checklist before matching. Scenario digests are salted by *axis* (business versus arch) but **not** by service, so a digest identifies scenario words; `--service` is what gives a report an owner.
|
|
299
|
+
|
|
300
|
+
### Attested is not verified
|
|
301
|
+
|
|
302
|
+
A service with no runnable suite yet must still be able to record its answers, so `--record` may confirm a `scenario.tested` claim too. That path is kept and made **visible**, which is the whole of the decision:
|
|
303
|
+
|
|
304
|
+
- the claim's `answered_by` reads `agent` rather than `runner` (an answer written before `--results` existed points at no run, so an absent `answered_by` counts as `agent`);
|
|
305
|
+
- the record's **`verdict`** is one of three values, recomputed from `claims[]` on every read and never taken from the `summary:` block: `verified` (every claim confirmed, every scenario claim from a run, and at least one claim exists), `attested` (every claim confirmed, but at least one scenario claim rests on an agent's word), `unverified` (anything else). `verified` in `--json` is exactly `verdict === "verified"`, so a record with zero claims can no longer read as verified by arithmetic;
|
|
306
|
+
- `attested` (a count) and `notices[]` ride in the same payload, `verify` prints `verify.scenario-attested` (**warning**, gating nothing) naming each claim, and the closing line says the record travels into `features/archive/` saying so;
|
|
307
|
+
- `loam status` re-reports the same finding in `checks.issues[]` and offers `next.verify-attested`; a feature in that state reaches `stage: ready`, never `done`;
|
|
308
|
+
- the frozen post-archive view recounts from `claims[]` as well. The record's own `summary:` is still echoed verbatim in `--json` — that is the frozen view's promise — but nothing is *derived* from it, and a `summary` that contradicts its own `claims[]` makes the whole record `record-unreadable` (`verify.record-miscounted`): the file contradicts itself, so neither half can be believed and neither is reported as fact.
|
|
309
|
+
|
|
310
|
+
So "a green run is the only thing that confirms a scenario" is a claim about the **record**, not about the CLI's flags: the short path still works, and it is legible afterwards as the short path.
|
|
311
|
+
|
|
312
|
+
### The record is federated: one file, one attestation per service
|
|
313
|
+
|
|
314
|
+
A cross-service feature's claims belong to several services, and each service's code lives in its own repository — so no single place can honestly answer all of them. `loam verify <FEAT> --service <id> --record answers.json`, run **in that service's own repo**, narrows the checklist to that service's claims and binds the answers to this repository's commit. The result is one `schema: 2` `verification.yaml` holding a list of **`attestations`**, one per service: the service id, the commit the answers were bound to, and that service's answered claims.
|
|
315
|
+
|
|
316
|
+
The merge semantics are additive and non-destructive. Recording for service B keeps service A's attestation exactly as it stood — the new record is the old attestations minus B's previous one, plus B's new one. Answers the new record does *not* carry are reported in `discarded`, tagged `unattested` (a schema-1 answer, or one whose attestation is gone) or `off-checklist` (a claim the current checklist no longer asks). Nothing is dropped silently.
|
|
317
|
+
|
|
318
|
+
Recording refuses rather than guesses, each under its own `--json` `error.code`:
|
|
319
|
+
|
|
320
|
+
- `service-mismatch` — `--service` names a service this repository's `loam.json` does not declare. A repo may only attest for itself.
|
|
321
|
+
- `unknown-service` — the named service is not part of this feature's checklist.
|
|
322
|
+
- `repository-unavailable` — `--service` from a repo that declares none, or one whose commit cannot be read. An attestation with nothing to bind to is not an attestation.
|
|
323
|
+
- **`record-federated`** — the legacy all-at-once form (`--record` with **no** `--service`) over a record that already carries attestations. That form writes the whole file from one place, so it would erase every other repository's evidence; loam refuses outright and names the attestors, and never silently migrates or merges. Re-run with `--service`.
|
|
324
|
+
- `record-unreadable` — see below.
|
|
325
|
+
|
|
326
|
+
Reading is unrestricted: `loam verify <FEAT> --service <id>` **without** `--record` is a pure lens on the checklist, writes nothing, and works from the docs repo too. Every run reports all subjects in `services`, which is how you find out which repositories still owe an attestation.
|
|
327
|
+
|
|
328
|
+
The record is YAML, like the other data artifacts, and holds the claim text next to each verdict so it reads without loam. Because it has to survive without loam, human edits are expected — and read back defensively. A file that exists but does not read as a record (broken YAML, or a shape missing what the readers use — `summary` with its counts, `recorded`, `checklist`, claims each with an `id` and a known `verdict`) is **`record-unreadable`**: reported with the YAML line, and **never overwritten**. It used to count as *no record at all*, which was the wrong direction on the one artifact loam cannot reconstruct — "no record" invites writing a fresh one over evidence somebody's edit merely broke. Only a genuinely absent file is "no verification record". Extra keys a human adds still pass through untouched. Every verdict names who answered it — `answered_by: runner` (a report's green run, mechanical) or `answered_by: agent` (somebody's word about the code) — so a reviewer can tell the two apart at a glance:
|
|
329
|
+
|
|
330
|
+
```yaml
|
|
331
|
+
feature: FEAT-101
|
|
332
|
+
recorded: 2026-08-03
|
|
333
|
+
checklist: 4ae5ab9fc17df302 # digest of the claim ids these answer
|
|
334
|
+
summary: { claims: 4, confirmed: 3, unconfirmed: 1 }
|
|
335
|
+
claims:
|
|
336
|
+
- id: api.exposes-2a8cee76
|
|
337
|
+
kind: api.exposes
|
|
338
|
+
claim: payment-split-service exposes operationId 'createSplit'
|
|
339
|
+
verdict: confirmed
|
|
340
|
+
answered_by: agent
|
|
341
|
+
evidence: [src/split/Api.ts:42]
|
|
342
|
+
- id: scenario.tested-daed1f53
|
|
343
|
+
kind: scenario.tested
|
|
344
|
+
claim: scenario 'Split across two payees' … is covered by a test
|
|
345
|
+
verdict: unconfirmed
|
|
346
|
+
answered_by: runner
|
|
347
|
+
note: "failed at step 3 (report.json: features/loam/split-a-payment.feature › Split across two payees)"
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
It lives inside the feature, so `archive` carries it into `features/archive/<FEAT>/` with everything else. `loam verify` re-run later compares `checklist` against the current digest and reports the record as **stale** if the feature moved under it — while the feature is active. Once archived there is no current checklist to be stale against (the merge itself moved the feature's operations into the living OpenAPI, so a re-derived checklist could only disagree): `loam verify` on an archived feature renders the record verbatim as **frozen history** (`frozen: true` under `--json`, no staleness judgment), and `--record` refuses (`invalid-option` — there is no current checklist for the answers to answer): the record is about the words that shipped, and it stays that way.
|
|
351
|
+
|
|
352
|
+
**Verification does not gate `archive`.** Coherence gates because loam computed it from the documents; a verdict is an agent's word about code loam never read, and a gate in front of shipping teaches everyone that the cheapest way past it is to say yes. The record is for the reviewer who comes later, and an `unconfirmed` claim with a note is worth more there than a `confirmed` nobody can back up. `loam verify` exits 0 either way; branch on `verified` in the `--json` payload, or on `verdict` when you need to tell `attested` from `unverified`.
|
|
353
|
+
|
|
354
|
+
## Operating at fleet scale
|
|
355
|
+
|
|
356
|
+
One docs repo, a hundred services, many teams. The repo scales the way any shared codebase does — through the forge, not through loam features:
|
|
357
|
+
|
|
358
|
+
- **The docs repo bootstraps itself.** In the empty directory that will hold it, `loam init --docs . --create` scaffolds `services/`, `features/`, `architecture/landscape.likec4`, `AGENTS.md` **and the docs repo's own `loam.json`** (`{"docsDir": "."}`) — which is what makes `loam validate --all`, `loam list` and `loam archive` work when run *inside* the docs repo, rather than only from a service repo pointing at it. Commit all of it. Each service repo then joins with `loam init --docs ../docs-repo --service <id>` (no `--create`: it must find an existing docs repo) and confirms the wiring with `loam doctor`. There is no batch onboarding command and no fleet manifest: the wiring is one committed file per repo, reviewable like anything else, and N repos are N independent one-line commits rather than one command nobody can audit.
|
|
359
|
+
- **Ownership is CODEOWNERS.** `services/<svc>/**` belongs to the owning team; `features/**` to whoever drives cross-service change, with the owners of each touched service pulled in through the feature's `specs/<svc>/` paths. loam has no permission model and should not grow one — the forge already has it.
|
|
360
|
+
- **`vouch` and `archive` land through reviewed PRs.** Both rewrite the source of truth, and the PR supplies exactly what loam deliberately does not record: who approved, when, and the diff they saw. The voucher's identity is the PR author's git identity — no `vouched_by:` field, because git already refuses to forget.
|
|
361
|
+
- **Provenance runs where the code is.** `sources` resolve only inside a service's own repo, so each service repo's CI runs `loam validate --service <id> --json` and branches on the finding codes (`sources.current`, `sources.stale`, `sources.unvouched`). The docs repo's CI runs `loam validate --all`, which counts what it cannot check from there (`sourcesUnverifiableFromHere`). Aggregating the per-repo results into a fleet view happens outside loam, on purpose: the stable codes are the interface, and any CI system can pivot on them.
|
|
362
|
+
- **What it costs, measured.** On a generated 120-service fleet with 400 and 800 op-linked landscape edges (compiled binary, 8-core laptop, wall clock including Node startup): `loam list --json` 1.1–1.5 s, and `loam validate --service <id> --json` — the command above, the one every service repo runs — 1.4–1.6 s at either edge count. Both are flat in edge count because loam reads LikeC4's **parsed** model and never computes a view: it renders nothing, and view computation is superlinear in edges. (If you want diagrams, add a `views` block and render it with LikeC4's own tooling — `npx likec4 start` — and scope the views, because an `include *` over a fleet-sized landscape takes minutes. `loam init` scaffolds the landscape with no `views` block for that reason: loam should not plant a diagram it never draws.) `loam validate --all --json` over the same 120 services is **~30 s**, an order of magnitude worse than the rest, because it parses a fresh LikeC4 workspace per service rather than sharing one. That is a known, unclosed cost. It is the docs repo's own CI job, and it is nobody's inner loop.
|
|
363
|
+
|
|
364
|
+
Four read-only fleet/transition views make that filesystem contract inspectable without creating or repairing anything:
|
|
365
|
+
|
|
366
|
+
- **`loam status [<FEAT>] [--json]`** answers "where does this stand and what do I do next", derived every run from the files — there is no state file to go stale. Per feature it grades each artifact `missing`/`blocked`/`draft`/`ready`/`done`, and `next[]` is ordered most-unblocking first, each entry a stable `next.*` code plus the literal command. It is a **projection over the gates, never a second opinion**: `stage` is taken from the union of what `validate --feature` errors on and what `archive` refuses to merge (severity *or* `gates`), so status may be more pessimistic than either and is never greener than both — an invariant with a test behind it. The fleet form is capped at ten substantive steps plus a `next.elided` notice saying how many were left out, and whenever anything is outstanding its **last** entry is `next.fleet-gate` → `loam validate --all --json` (a repository with work in it must never get a list that omits the command CI runs; a fleet with nothing outstanding says so instead, with `next.fleet-clean`). The cap is applied before the gate entry, so the gate is never the thing elided. It deliberately does not parse the landscape: that is a LikeC4 workspace spin, which is exactly the cost the fleet form refuses to pay, and `next.fleet-gate` is the handoff.
|
|
367
|
+
|
|
368
|
+
- **`loam doctor [--json]`** reads `loam.json` defensively (including corrupt JSON), resolves its `docsDir` against the config location, probes existence/read/write access, checks `services/`, **reads** the landscape rather than merely stat-ing it, inventories service/active-feature counts, and reports the current `service` binding. Every finding carries a `fix`. `healthy: false` and exit 1 are reserved for blockers: missing/invalid config, missing docsDir, missing `services/`, and the three states in which the fleet map cannot be read at all — `doctor.landscape-merge-conflict` (it still holds `<<<<<<<` markers: it is two halves of two different maps, and LikeC4 may even parse it), `doctor.landscape-invalid` (it does not parse) and `doctor.landscape-unreadable` (it exists but could not be read). An *absent* landscape stays a doctor warning — `loam validate --all` is where that becomes the error `landscape.missing`, because only there is it known whether any service exists to be drawn. Portability, absent/unknown service binding and access limitations are also warnings, not attempted repairs: `doctor.docs-absolute` flags a `docsDir` stored as an absolute path in a committed `loam.json`, which resolves only on the machine that ran `loam init`. The JSON result is a successful v1.0 envelope even when unhealthy because the diagnosis itself ran; branch on `healthy` and `findings[].severity`.
|
|
369
|
+
|
|
370
|
+
Doctor also reports the **write path**, as state and as findings, so a repo whose last write did not finish is never called healthy: `doctor.commit-interrupted` (blocker — an interrupted `archive`/`unarchive`; the fix re-runs that exact command, which recovers under the lock), `doctor.commit-unreadable` (blocker — `.loam-commit` exists and cannot be parsed, so nothing can grade it), `doctor.docs-locked` (a warning while the lock's holder is alive, a blocker once the holder is a process that no longer exists on this host, because nothing will ever release it) and `doctor.staging-temps` (warning — orphaned `.loam-*.tmp` files a killed writer never linked into place; litter, not damage). The whole picture rides in `--json` as a `writePath` block.
|
|
371
|
+
|
|
372
|
+
And it reports the **agent surface**: which tool the repo is wired for, how many command and skill files it holds, how many a newer loam would lay down (`doctor.agent-files-missing`), and how many have fallen behind (`doctor.agent-files-stale`). The staleness check reads exactly one thing — the `<!-- generated by loam vX.Y.Z -->` line every generated body opens with — and nothing else. Comparing bodies would yield "your file differs from the template", which is one step from offering to rewrite it, and loam never rewrites a generated file; a stamp is a different and smaller claim, and it can be false while every edit around it is legitimate. Which deliveries a repo holds is asked of the files rather than of a record: `loam.json` stores tools, not deliveries, and a repo initialized `--no-skills` is not reported as missing six skills.
|
|
373
|
+
- **`loam dependencies [<FEAT>] [--json]`** derives a deterministic graph from parsed active artifacts, never from validator message prose. A MODIFIED/REMOVED requirement depends on the active feature that ADDS the same stable `Requirement-ID` (or legacy exact name); an operation reference/removal depends on the active feature whose OpenAPI delta introduces that `operationId`. Two active features adding the same requirement identity or operationId are conflicts. JSON exposes `nodes`, grouped `edges[].reasons`, `conflicts`, dependency-first `order`, and strongly-connected `cycles`; a focused feature includes all of its transitive prerequisites.
|
|
374
|
+
- **`loam audit-openspec <root> [--write-mapping <path>] [--json]`** inventories modern config/Store roots, the union of nested living and active-only capabilities, active and archived changes, per-change schema/`skip_specs`, RENAMED FROM/TO pairs and artifact dispositions. Living/active blockers determine `mechanicallyCompatible`; frozen `archiveDiagnostics` never do. A completed audit exits successfully even with findings. An explicit `--write-mapping` writes a non-overwriting versioned skeleton outside the source, bound to the canonical root and a SHA-256 inventory digest **over the living tree and active changes only** — frozen history never gates, so it must not be able to invalidate a completed mapping either. Its `changes.<OpenSpec-id>` entries require a unique loam feature id and title; suggestions remain null decisions. A root holding no living spec and no active change is never `ready` (`openspec.workspace-empty`): a verdict over a corpus nobody read is the one answer this command must not be able to give.
|
|
375
|
+
- **`loam migrate-openspec <root> --map <path> [--apply --target <empty-dir>] [--json]`** repeats the audit, verifies that source digest, resolves capability→one-or-many-service mappings (with per-requirement allocation across living and active requirements), explicit change→feature decisions, validated rename identities, and proposal/design/tasks dispositions. RENAMED FROM must resolve to exactly one living requirement; an existing `Requirement-ID` is reused, otherwise the mapping supplies one, and conflicting rename chains are refused. It is dry-run by default and never creates a target. `--apply --target` transactionally materializes **staged migration docs** — draft living service specs, active feature intents and routed delta specs, deterministic ADR/legacy outputs, an exact feature-local copy of every source change artifact, a verbatim copy of the living capability tree under `legacy/openspec/specs/`, normalized mapping, plan and follow-up blockers — into a separate empty directory, together with that directory's own `loam.json`, `AGENTS.md` and an empty `architecture/landscape.likec4`, so the staged target is a standalone docs repo the follow-up instructions can actually be run inside. The target may not sit inside a live loam fleet (`docsDir` of a governing `loam.json`, or any directory holding `architecture/landscape.likec4`); otherwise every staged feature would be enumerated as a phantom feature of the real fleet. A `skip_specs: true` change gets intent/legacy material but no spec delta. The result remains deliberately non-green because C4/OpenAPI/provenance/vouch work is not invented.
|
|
376
|
+
|
|
377
|
+
**The adoption readout measures presence, not truth.** `loam list --json` grades every service on one monotone ladder — `empty` (a directory and nothing else) → `partial` (some artifacts, but not the required triple) → `documented` (`model.likec4` + `spec.md` + `openapi.yaml` all present) → `sourced` (the living spec declares `sources`) → `vouched` (`status: verified` with a `sources_digest` behind it) — as a `maturity` string per service and a fleet rollup of counts per rung, with the same rollup as a line in the text view. Every rung is derived from artifact presence and provenance state alone; COMPLETENESS of what was written is on the adopt brief's unchecked list, so no rung is called "adopted" — a service with one endpoint documented out of thirty reaches `vouched` exactly as fast as a thorough one, and only a reader can tell them apart.
|
|
378
|
+
|
|
379
|
+
**The per-service summary a CI pipeline publishes.** Staleness and validity are computable only one service repo at a time, and loam ships no aggregator — but a fleet view needs a defined thing to aggregate, and without one "no report" is indistinguishable from "clean". So the contract is a shape, not a tool. Each service repo's CI derives this from `loam validate --service <id> --json` and publishes it wherever the fleet view reads:
|
|
380
|
+
|
|
381
|
+
```json
|
|
382
|
+
{
|
|
383
|
+
"service": "payment-service",
|
|
384
|
+
"valid": true,
|
|
385
|
+
"errors": 0,
|
|
386
|
+
"warnings": 2,
|
|
387
|
+
"sources": { "current": 1, "stale": 0, "unvouched": 0, "absent": 0 },
|
|
388
|
+
"generatedAt": "2026-08-03T12:00:00Z"
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The derivation, verbatim (`jq`; the same logic fits a `node -e` one-liner):
|
|
393
|
+
|
|
394
|
+
```sh
|
|
395
|
+
loam validate --service "$SVC" --json | jq '{
|
|
396
|
+
service: .targets[0].id,
|
|
397
|
+
valid: .valid,
|
|
398
|
+
errors: .summary.errors,
|
|
399
|
+
warnings: .summary.warnings,
|
|
400
|
+
sources: {
|
|
401
|
+
current: [.targets[0].findings[] | select(.code == "sources.current")] | length,
|
|
402
|
+
stale: [.targets[0].findings[] | select(.code == "sources.stale")] | length,
|
|
403
|
+
unvouched: [.targets[0].findings[] | select(.code == "sources.unvouched")] | length,
|
|
404
|
+
absent: [.targets[0].findings[] | select(.code == "sources.absent")] | length
|
|
405
|
+
},
|
|
406
|
+
generatedAt: (now | todate)
|
|
407
|
+
}'
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`generatedAt` is the CI's clock, never loam's — loam output carries no timestamps, by design, so the same repo state always yields the same bytes. That is also what makes the timestamp the liveness signal: the rule is that every service repo's CI publishes this summary on every run, so a summary that is missing or whose `generatedAt` is old IS the "no report" state, and a fleet view renders it as unknown, never as clean. The derivation is pinned in `test/validate-contract.test.ts`, so the payload cannot drift out from under this recipe silently.
|
|
411
|
+
|
|
412
|
+
**One `landscape.likec4`, by decision.** A shared file every feature merges into sounds like a conflict factory, but archive's insertion is service-grouped and order-independent: additions land beside their service's existing elements and edges (new services in an id-sorted trailing region), so two concurrent archives touching **different** services usually splice different lines and git merges their PRs without help. What that does not eliminate is two archives touching the **same** service — those land adjacently by design, and their PRs conflict. The accepted process for now is AI-assisted human review in the PR flow — the same posture OpenSpec takes for same-capability conflicts, left to the AI and human at archive time: the resolver reads both features and hand-merges the block. That resolution has a documented cost, not a surprise: a hand-resolved landscape moves the file away from the bytes the archive's `.loam-before/` snapshot recorded, so a later `loam unarchive` of that feature refuses with `snapshot-stale` and needs `--force` — the resolver is trading undo precision for the merge, and should know it. The trigger for revisiting is written down so nobody relitigates it early: when landscape merge conflicts become routine — weekly, not monthly — move per-service internals fully into `services/<svc>/model.likec4` and thin the landscape down to top-level elements plus cross-service edges. Until then, one file keeps the fleet map one diffable document.
|
|
413
|
+
|
|
414
|
+
## Two flows
|
|
415
|
+
|
|
416
|
+
- **Bootstrap (reverse):** `loam adopt --service <id>` emits a **brief**, not an extraction — the target paths (and which already exist, to be diffed and never overwritten), the grammar of each artifact, what the living landscape already says about the service, the frontmatter to write, the checks `loam validate --service <id>` will then run, and the ones that do not exist. One target is not under `services/<id>/`: `architecture/landscape.likec4`, carried with `action: "edit"` and a `landscape.instruction` naming the block this service owes the fleet map, and present only while nothing in the map resolves to the service. An agent reads the code and writes draft `model.likec4` + `spec.md` + `openapi.yaml` + `adrs/`, `runbook.md`, `health.yaml`, and makes that one addition to the shared map. A human promotes `draft` -> `verified` with `loam vouch`.
|
|
417
|
+
- **Forward (generative):** author `features/<FEAT>/delta.likec4` -> `loam delta <FEAT>` projects it per-service into work -> `loam gherkin <FEAT>` emits the scenarios as the executable `.feature` skeleton in the service's repo -> tests -> code -> `loam verify <FEAT> --service <id>` records, from each affected service's own repo, what was actually built.
|
|
418
|
+
|
|
419
|
+
There is no code extractor on either side, by decision. Nothing deterministic reads a service and says what its architecture means, and two generated models of the same code disagree in wording every run — a done-check built on diffing them would flap until somebody turned it off. What is deterministic is the **question**: which files, in which grammar, bound to which existing elements; which operations, which edges, which scenarios. loam owns the questions and the checking; an agent owns the reading. `sources` and `verification.yaml` are where the answers are written down.
|
|
420
|
+
|
|
421
|
+
## Status
|
|
422
|
+
|
|
423
|
+
`init`, `list` / `show` / `status` (navigation and what-to-do-next), `doctor` / `dependencies` (preflight and the active-feature graph), `adopt` (the baseline brief), `new` (feature scaffolding), `validate` (C4 + requirement + API coverage + cross-axis coherence + the landscape ↔ `services/` cross-check, single target or `--all`), `delta` (per-service projection), `gherkin` (the generated `.feature` suite in the service repo), `rebase` (pin a delta to the living text it was written against), `verify` (the done-check checklist + its record — scenario claims answered by the cucumber report via `--results`, or attested via `--record`), `archive` (three-axis merge, gated on gating coherence issues) / `unarchive` (put it back from the snapshot archive left behind), `vouch` (stamp a spec verified against the code it describes), and `audit-openspec` / `migrate-openspec` (the one-way on-ramp) are implemented, each with a `--json` contract. Remaining: `render` (diagrams — delegated to LikeC4's own tooling), `health` compose, UI-prototype generation.
|
|
424
|
+
|
|
425
|
+
Known and unclosed, so nobody has to discover them: `loam validate --all` costs ~30 s on a 120-service fleet (one LikeC4 workspace per service — see "Operating at fleet scale"); `delta.service-unknown` reddens `validate` but does not yet gate `archive`, which gates on coherence rather than on the feature validator; a path-level `x-loam-remove` already sitting in a *living* contract is gated at archive time but not yet reported by `validate`; and `loam list`'s verification column shows a confirmed/claims count without distinguishing an attested record from a verified one — `loam verify` and `loam status` are where that distinction is currently legible.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Loam is pre-release software. Until the first public release, there is no supported stable line. After publication, security fixes target the latest published release; older prereleases may be asked to upgrade rather than receive a backport.
|
|
4
|
+
|
|
5
|
+
## Reporting a vulnerability
|
|
6
|
+
|
|
7
|
+
Do not disclose a suspected vulnerability in a public issue, pull request, discussion, or pilot scorecard. Report it privately through GitHub Private Vulnerability Reporting — **[Report a vulnerability](https://github.com/ybotok/loam/security/advisories/new)**, also reachable from the repository's Security tab — and include the affected Loam version, impact, minimal reproduction, and any known workaround.
|
|
8
|
+
|
|
9
|
+
Please allow the maintainers time to confirm receipt and coordinate remediation before public disclosure. Never include production credentials, customer data, or proprietary fleet documents in a report; use a reduced fixture.
|