@ybotok/loam 0.1.0-beta.3 → 0.2.0-alpha.1
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 +2548 -86
- package/COMPARISON.md +80 -63
- package/CONTRIBUTING.md +84 -0
- package/MIGRATING-from-OpenSpec.md +213 -54
- package/QUICKSTART.md +125 -0
- package/README.md +450 -150
- package/ROADMAP.md +494 -0
- package/SCHEMA.md +2355 -256
- package/SECURITY.md +21 -3
- package/WORKFLOW.md +701 -0
- package/dist/cli.js +113 -19
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt/adopt.js +209 -0
- package/dist/commands/adopt/adopt.js.map +1 -0
- package/dist/commands/adopt/render.js +268 -0
- package/dist/commands/adopt/render.js.map +1 -0
- package/dist/commands/archive/archive.js +77 -0
- package/dist/commands/archive/archive.js.map +1 -0
- package/dist/commands/archive/plan/contracts/asyncapi.js +178 -0
- package/dist/commands/archive/plan/contracts/asyncapi.js.map +1 -0
- package/dist/commands/archive/plan/contracts/openapi.js +159 -0
- package/dist/commands/archive/plan/contracts/openapi.js.map +1 -0
- package/dist/commands/archive/plan/gate.js +317 -0
- package/dist/commands/archive/plan/gate.js.map +1 -0
- package/dist/commands/archive/plan/landscape.js +161 -0
- package/dist/commands/archive/plan/landscape.js.map +1 -0
- package/dist/commands/archive/plan/refusal.js +111 -0
- package/dist/commands/archive/plan/refusal.js.map +1 -0
- package/dist/commands/archive/plan/specs.js +237 -0
- package/dist/commands/archive/plan/specs.js.map +1 -0
- package/dist/commands/archive/plan/state.js +27 -0
- package/dist/commands/archive/plan/state.js.map +1 -0
- package/dist/commands/archive/run.js +251 -0
- package/dist/commands/archive/run.js.map +1 -0
- package/dist/commands/context/context.js +99 -0
- package/dist/commands/context/context.js.map +1 -0
- package/dist/commands/context/print.js +283 -0
- package/dist/commands/context/print.js.map +1 -0
- package/dist/commands/delta/delta.js +279 -0
- package/dist/commands/delta/delta.js.map +1 -0
- package/dist/commands/delta/print.js +205 -0
- package/dist/commands/delta/print.js.map +1 -0
- package/dist/commands/dependencies.js +9 -7
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/diff/diff.js +93 -0
- package/dist/commands/diff/diff.js.map +1 -0
- package/dist/commands/diff/print.js +38 -0
- package/dist/commands/diff/print.js.map +1 -0
- package/dist/commands/doctor.js +5 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/explain/explain.js +151 -0
- package/dist/commands/explain/explain.js.map +1 -0
- package/dist/commands/explore.js +29 -24
- package/dist/commands/explore.js.map +1 -1
- package/dist/commands/gate/gate.js +115 -0
- package/dist/commands/gate/gate.js.map +1 -0
- package/dist/commands/gate/print.js +54 -0
- package/dist/commands/gate/print.js.map +1 -0
- package/dist/commands/gherkin/commit.js +146 -0
- package/dist/commands/gherkin/commit.js.map +1 -0
- package/dist/commands/gherkin/gherkin.js +246 -0
- package/dist/commands/gherkin/gherkin.js.map +1 -0
- package/dist/commands/gherkin/reconcile.js +149 -0
- package/dist/commands/gherkin/reconcile.js.map +1 -0
- package/dist/commands/gherkin/render.js +77 -0
- package/dist/commands/gherkin/render.js.map +1 -0
- package/dist/commands/init/example.js +204 -0
- package/dist/commands/init/example.js.map +1 -0
- package/dist/commands/init/first-hour.js +28 -0
- package/dist/commands/init/first-hour.js.map +1 -0
- package/dist/commands/{init.js → init/init.js} +146 -71
- package/dist/commands/init/init.js.map +1 -0
- package/dist/commands/init/mcp.js +131 -0
- package/dist/commands/init/mcp.js.map +1 -0
- package/dist/commands/init/options.js +78 -0
- package/dist/commands/init/options.js.map +1 -0
- package/dist/commands/instructions.js +72 -14
- package/dist/commands/instructions.js.map +1 -1
- package/dist/commands/list/campaign/campaign.js +65 -0
- package/dist/commands/list/campaign/campaign.js.map +1 -0
- package/dist/commands/list/campaign/owners.js +65 -0
- package/dist/commands/list/campaign/owners.js.map +1 -0
- package/dist/commands/list/json.js +157 -0
- package/dist/commands/list/json.js.map +1 -0
- package/dist/commands/list/list.js +343 -0
- package/dist/commands/list/list.js.map +1 -0
- package/dist/commands/list/print.js +301 -0
- package/dist/commands/list/print.js.map +1 -0
- package/dist/commands/list/review.js +120 -0
- package/dist/commands/list/review.js.map +1 -0
- package/dist/commands/list/views.js +105 -0
- package/dist/commands/list/views.js.map +1 -0
- package/dist/commands/mcp/dispatch.js +183 -0
- package/dist/commands/mcp/dispatch.js.map +1 -0
- package/dist/commands/mcp/mcp.js +34 -0
- package/dist/commands/mcp/mcp.js.map +1 -0
- package/dist/commands/mcp/serve.js +55 -0
- package/dist/commands/mcp/serve.js.map +1 -0
- package/dist/commands/migrate-openspec/materialize/active.js +170 -0
- package/dist/commands/migrate-openspec/materialize/active.js.map +1 -0
- package/dist/commands/migrate-openspec/materialize/feature.js +41 -0
- package/dist/commands/migrate-openspec/materialize/feature.js.map +1 -0
- package/dist/commands/migrate-openspec/materialize/living.js +62 -0
- package/dist/commands/migrate-openspec/materialize/living.js.map +1 -0
- package/dist/commands/migrate-openspec/materialize/stage.js +142 -0
- package/dist/commands/migrate-openspec/materialize/stage.js.map +1 -0
- package/dist/commands/migrate-openspec/materialize/target.js +219 -0
- package/dist/commands/migrate-openspec/materialize/target.js.map +1 -0
- package/dist/commands/migrate-openspec/migrate-openspec.js +142 -0
- package/dist/commands/migrate-openspec/migrate-openspec.js.map +1 -0
- package/dist/commands/migrate-openspec/openspec/decisions.js +91 -0
- package/dist/commands/migrate-openspec/openspec/decisions.js.map +1 -0
- package/dist/commands/migrate-openspec/openspec/error.js +19 -0
- package/dist/commands/migrate-openspec/openspec/error.js.map +1 -0
- package/dist/commands/migrate-openspec/openspec/mapping.js +170 -0
- package/dist/commands/migrate-openspec/openspec/mapping.js.map +1 -0
- package/dist/commands/migrate-openspec/openspec/paths.js +43 -0
- package/dist/commands/migrate-openspec/openspec/paths.js.map +1 -0
- package/dist/commands/migrate-openspec/openspec/read.js +49 -0
- package/dist/commands/migrate-openspec/openspec/read.js.map +1 -0
- package/dist/commands/migrate-openspec/print.js +89 -0
- package/dist/commands/migrate-openspec/print.js.map +1 -0
- package/dist/commands/new/commit.js +76 -0
- package/dist/commands/new/commit.js.map +1 -0
- package/dist/commands/new/new.js +283 -0
- package/dist/commands/new/new.js.map +1 -0
- package/dist/commands/new/templates.js +355 -0
- package/dist/commands/new/templates.js.map +1 -0
- package/dist/commands/open.js +203 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/policy/format.js +152 -0
- package/dist/commands/policy/format.js.map +1 -0
- package/dist/commands/{docs-repo-gate.js → policy/gate.js} +11 -11
- package/dist/commands/policy/gate.js.map +1 -0
- package/dist/commands/rebase/edit.js +106 -0
- package/dist/commands/rebase/edit.js.map +1 -0
- package/dist/commands/rebase/living.js +185 -0
- package/dist/commands/rebase/living.js.map +1 -0
- package/dist/commands/rebase/plan.js +245 -0
- package/dist/commands/rebase/plan.js.map +1 -0
- package/dist/commands/rebase/rebase.js +275 -0
- package/dist/commands/rebase/rebase.js.map +1 -0
- package/dist/commands/seed/commit.js +192 -0
- package/dist/commands/seed/commit.js.map +1 -0
- package/dist/commands/seed/plan.js +133 -0
- package/dist/commands/seed/plan.js.map +1 -0
- package/dist/commands/seed/seed.js +172 -0
- package/dist/commands/seed/seed.js.map +1 -0
- package/dist/commands/show/feature.js +178 -0
- package/dist/commands/show/feature.js.map +1 -0
- package/dist/commands/show/marks.js +24 -0
- package/dist/commands/show/marks.js.map +1 -0
- package/dist/commands/{show.js → show/service.js} +63 -152
- package/dist/commands/show/service.js.map +1 -0
- package/dist/commands/show/show.js +77 -0
- package/dist/commands/show/show.js.map +1 -0
- package/dist/commands/status/json.js +45 -0
- package/dist/commands/status/json.js.map +1 -0
- package/dist/commands/status/print.js +157 -0
- package/dist/commands/status/print.js.map +1 -0
- package/dist/commands/status/status.js +119 -0
- package/dist/commands/status/status.js.map +1 -0
- package/dist/commands/steps/steps.js +189 -0
- package/dist/commands/steps/steps.js.map +1 -0
- package/dist/commands/subsystem/edit.js +192 -0
- package/dist/commands/subsystem/edit.js.map +1 -0
- package/dist/commands/subsystem/move.js +265 -0
- package/dist/commands/subsystem/move.js.map +1 -0
- package/dist/commands/subsystem/query.js +115 -0
- package/dist/commands/subsystem/query.js.map +1 -0
- package/dist/commands/subsystem/subsystem.js +101 -0
- package/dist/commands/subsystem/subsystem.js.map +1 -0
- package/dist/commands/subsystem/sync.js +103 -0
- package/dist/commands/subsystem/sync.js.map +1 -0
- package/dist/commands/subsystem/txn/txn.js +94 -0
- package/dist/commands/subsystem/txn/txn.js.map +1 -0
- package/dist/commands/subsystem/txn/views.js +70 -0
- package/dist/commands/subsystem/txn/views.js.map +1 -0
- package/dist/commands/unarchive/manifest.js +160 -0
- package/dist/commands/unarchive/manifest.js.map +1 -0
- package/dist/commands/{unarchive.js → unarchive/restore.js} +47 -160
- package/dist/commands/unarchive/restore.js.map +1 -0
- package/dist/commands/unarchive/unarchive.js +67 -0
- package/dist/commands/unarchive/unarchive.js.map +1 -0
- package/dist/commands/validate/arch-coverage.js +194 -0
- package/dist/commands/validate/arch-coverage.js.map +1 -0
- package/dist/commands/validate/checks/fleet-shape.js +296 -0
- package/dist/commands/validate/checks/fleet-shape.js.map +1 -0
- package/dist/commands/validate/checks/health-deps.js +55 -0
- package/dist/commands/validate/checks/health-deps.js.map +1 -0
- package/dist/commands/validate/checks/requirements.js +254 -0
- package/dist/commands/validate/checks/requirements.js.map +1 -0
- package/dist/commands/validate/checks/sources.js +76 -0
- package/dist/commands/validate/checks/sources.js.map +1 -0
- package/dist/commands/validate/checks/vocabulary.js +31 -0
- package/dist/commands/validate/checks/vocabulary.js.map +1 -0
- package/dist/commands/validate/feature.js +216 -0
- package/dist/commands/validate/feature.js.map +1 -0
- package/dist/commands/validate/fleet/census.js +69 -0
- package/dist/commands/validate/fleet/census.js.map +1 -0
- package/dist/commands/validate/fleet/kind-tags.js +39 -0
- package/dist/commands/validate/fleet/kind-tags.js.map +1 -0
- package/dist/commands/validate/fleet/landscape.js +353 -0
- package/dist/commands/validate/fleet/landscape.js.map +1 -0
- package/dist/commands/validate/fleet/load.js +52 -0
- package/dist/commands/validate/fleet/load.js.map +1 -0
- package/dist/commands/validate/fleet/obligations.js +236 -0
- package/dist/commands/validate/fleet/obligations.js.map +1 -0
- package/dist/commands/validate/fleet/scorecard/adoption.js +102 -0
- package/dist/commands/validate/fleet/scorecard/adoption.js.map +1 -0
- package/dist/commands/validate/fleet/scorecard/contracts.js +98 -0
- package/dist/commands/validate/fleet/scorecard/contracts.js.map +1 -0
- package/dist/commands/validate/fleet/scorecard/print.js +63 -0
- package/dist/commands/validate/fleet/scorecard/print.js.map +1 -0
- package/dist/commands/validate/fleet/scorecard/scorecard.js +156 -0
- package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/capability-tag.js +94 -0
- package/dist/commands/validate/fleet/usecases/capability-tag.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/place.js +13 -0
- package/dist/commands/validate/fleet/usecases/place.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/requirement-tag.js +95 -0
- package/dist/commands/validate/fleet/usecases/requirement-tag.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/steps.js +197 -0
- package/dist/commands/validate/fleet/usecases/steps.js.map +1 -0
- package/dist/commands/validate/fleet/usecases/usecases.js +99 -0
- package/dist/commands/validate/fleet/usecases/usecases.js.map +1 -0
- package/dist/commands/validate/fleet/views/ids.js +40 -0
- package/dist/commands/validate/fleet/views/ids.js.map +1 -0
- package/dist/commands/validate/fleet/views/stale.js +59 -0
- package/dist/commands/validate/fleet/views/stale.js.map +1 -0
- package/dist/commands/validate/links/corpus.js +92 -0
- package/dist/commands/validate/links/corpus.js.map +1 -0
- package/dist/commands/validate/links/glossary.js +75 -0
- package/dist/commands/validate/links/glossary.js.map +1 -0
- package/dist/commands/validate/report.js +269 -0
- package/dist/commands/validate/report.js.map +1 -0
- package/dist/commands/validate/service/api.js +338 -0
- package/dist/commands/validate/service/api.js.map +1 -0
- package/dist/commands/validate/service/events/events.js +219 -0
- package/dist/commands/validate/service/events/events.js.map +1 -0
- package/dist/commands/validate/service/events/message-producers.js +77 -0
- package/dist/commands/validate/service/events/message-producers.js.map +1 -0
- package/dist/commands/validate/service/evidence-pins.js +131 -0
- package/dist/commands/validate/service/evidence-pins.js.map +1 -0
- package/dist/commands/validate/service/service.js +250 -0
- package/dist/commands/validate/service/service.js.map +1 -0
- package/dist/commands/validate/service/specs.js +219 -0
- package/dist/commands/validate/service/specs.js.map +1 -0
- package/dist/commands/validate/service/spine.js +113 -0
- package/dist/commands/validate/service/spine.js.map +1 -0
- package/dist/commands/validate/validate.js +338 -0
- package/dist/commands/validate/validate.js.map +1 -0
- package/dist/commands/verify/cross/diff.js +182 -0
- package/dist/commands/verify/cross/diff.js.map +1 -0
- package/dist/commands/verify/evidence/contract.js +32 -0
- package/dist/commands/verify/evidence/contract.js.map +1 -0
- package/dist/commands/verify/evidence/pins.js +52 -0
- package/dist/commands/verify/evidence/pins.js.map +1 -0
- package/dist/commands/verify/evidence/read.js +72 -0
- package/dist/commands/verify/evidence/read.js.map +1 -0
- package/dist/commands/verify/frozen.js +241 -0
- package/dist/commands/verify/frozen.js.map +1 -0
- package/dist/commands/verify/record.js +226 -0
- package/dist/commands/verify/record.js.map +1 -0
- package/dist/commands/verify/report.js +223 -0
- package/dist/commands/verify/report.js.map +1 -0
- package/dist/commands/verify/results.js +246 -0
- package/dist/commands/verify/results.js.map +1 -0
- package/dist/commands/verify/verify.js +217 -0
- package/dist/commands/verify/verify.js.map +1 -0
- package/dist/commands/vouch/contract.js +11 -0
- package/dist/commands/vouch/contract.js.map +1 -0
- package/dist/commands/vouch/pack/contract.js +2 -0
- package/dist/commands/vouch/pack/contract.js.map +1 -0
- package/dist/commands/vouch/pack/pack.js +266 -0
- package/dist/commands/vouch/pack/pack.js.map +1 -0
- package/dist/commands/vouch/pack/print.js +271 -0
- package/dist/commands/vouch/pack/print.js.map +1 -0
- package/dist/commands/vouch/pack/sections.js +142 -0
- package/dist/commands/vouch/pack/sections.js.map +1 -0
- package/dist/commands/vouch/prompt/confirm.js +46 -0
- package/dist/commands/vouch/prompt/confirm.js.map +1 -0
- package/dist/commands/vouch/report.js +101 -0
- package/dist/commands/vouch/report.js.map +1 -0
- package/dist/commands/vouch/run.js +285 -0
- package/dist/commands/vouch/run.js.map +1 -0
- package/dist/commands/vouch/sample/plan.js +125 -0
- package/dist/commands/vouch/sample/plan.js.map +1 -0
- package/dist/commands/vouch/sample/print.js +109 -0
- package/dist/commands/vouch/sample/print.js.map +1 -0
- package/dist/commands/vouch/vet/verify.js +123 -0
- package/dist/commands/vouch/vet/verify.js.map +1 -0
- package/dist/commands/vouch/vouch.js +176 -0
- package/dist/commands/vouch/vouch.js.map +1 -0
- package/dist/core/agent/agents-md/artifacts.js +220 -0
- package/dist/core/agent/agents-md/artifacts.js.map +1 -0
- package/dist/core/agent/agents-md/command-map.js +324 -0
- package/dist/core/agent/agents-md/command-map.js.map +1 -0
- package/dist/core/agent/agents-md/cycle.js +165 -0
- package/dist/core/agent/agents-md/cycle.js.map +1 -0
- package/dist/core/agent/agents-md/map/explain.js +34 -0
- package/dist/core/agent/agents-md/map/explain.js.map +1 -0
- package/dist/core/agent/agents-md/map/lenses/context.js +41 -0
- package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -0
- package/dist/core/agent/agents-md/map/lenses/diff.js +46 -0
- package/dist/core/agent/agents-md/map/lenses/diff.js.map +1 -0
- package/dist/core/agent/agents-md/map/lenses/gate.js +57 -0
- package/dist/core/agent/agents-md/map/lenses/gate.js.map +1 -0
- package/dist/core/agent/agents-md/map/mcp.js +41 -0
- package/dist/core/agent/agents-md/map/mcp.js.map +1 -0
- package/dist/core/agent/agents-md/map/status.js +62 -0
- package/dist/core/agent/agents-md/map/status.js.map +1 -0
- package/dist/core/agent/agents-md/map/subsystem.js +32 -0
- package/dist/core/agent/agents-md/map/subsystem.js.map +1 -0
- package/dist/core/agent/agents-md/map/usecases.js +60 -0
- package/dist/core/agent/agents-md/map/usecases.js.map +1 -0
- package/dist/core/agent/agents-md/refusals.js +246 -0
- package/dist/core/agent/agents-md/refusals.js.map +1 -0
- package/dist/core/agent/agents-md/shipped/archive-gate.js +98 -0
- package/dist/core/agent/agents-md/shipped/archive-gate.js.map +1 -0
- package/dist/core/agent/agents-md/spine.js +40 -0
- package/dist/core/agent/agents-md/spine.js.map +1 -0
- package/dist/core/agent/agents-md.js +170 -0
- package/dist/core/agent/agents-md.js.map +1 -0
- package/dist/core/{agents-stamp.js → agent/agents-stamp.js} +50 -0
- package/dist/core/agent/agents-stamp.js.map +1 -0
- package/dist/core/agent/contract.js +2 -0
- package/dist/core/agent/contract.js.map +1 -0
- package/dist/core/agent/protocol.js +193 -0
- package/dist/core/agent/protocol.js.map +1 -0
- package/dist/core/agent/scaffold.js +259 -0
- package/dist/core/agent/scaffold.js.map +1 -0
- package/dist/core/agent/support/report.js +103 -0
- package/dist/core/agent/support/report.js.map +1 -0
- package/dist/core/agent/tools/dialects.js +150 -0
- package/dist/core/agent/tools/dialects.js.map +1 -0
- package/dist/core/agent/tools/registry.js +228 -0
- package/dist/core/agent/tools/registry.js.map +1 -0
- package/dist/core/agent/workflows/adopt.js +133 -0
- package/dist/core/agent/workflows/adopt.js.map +1 -0
- package/dist/core/agent/workflows/check.js +334 -0
- package/dist/core/agent/workflows/check.js.map +1 -0
- package/dist/core/agent/workflows/closing.js +262 -0
- package/dist/core/agent/workflows/closing.js.map +1 -0
- package/dist/core/agent/workflows/feature.js +105 -0
- package/dist/core/agent/workflows/feature.js.map +1 -0
- package/dist/core/agent/workflows/implement.js +105 -0
- package/dist/core/agent/workflows/implement.js.map +1 -0
- package/dist/core/agent/workflows/reference/authoring.js +209 -0
- package/dist/core/agent/workflows/reference/authoring.js.map +1 -0
- package/dist/core/agent/workflows/reference/codes.js +65 -0
- package/dist/core/agent/workflows/reference/codes.js.map +1 -0
- package/dist/core/agent/workflows/reference/done-check.js +188 -0
- package/dist/core/agent/workflows/reference/done-check.js.map +1 -0
- package/dist/core/agent/workflows/reference/reference.js +16 -0
- package/dist/core/agent/workflows/reference/reference.js.map +1 -0
- package/dist/core/agent/workflows/reference/spine.js +297 -0
- package/dist/core/agent/workflows/reference/spine.js.map +1 -0
- package/dist/core/asyncapi/baseline/plan.js +110 -0
- package/dist/core/asyncapi/baseline/plan.js.map +1 -0
- package/dist/core/asyncapi/baseline/surfaces.js +54 -0
- package/dist/core/asyncapi/baseline/surfaces.js.map +1 -0
- package/dist/core/asyncapi/depth.js +164 -0
- package/dist/core/asyncapi/depth.js.map +1 -0
- package/dist/core/asyncapi/digest.js +141 -0
- package/dist/core/asyncapi/digest.js.map +1 -0
- package/dist/core/asyncapi/merge/components.js +91 -0
- package/dist/core/asyncapi/merge/components.js.map +1 -0
- package/dist/core/asyncapi/merge/error.js +33 -0
- package/dist/core/asyncapi/merge/error.js.map +1 -0
- package/dist/core/asyncapi/merge/markers.js +308 -0
- package/dist/core/asyncapi/merge/markers.js.map +1 -0
- package/dist/core/asyncapi/merge/merge.js +215 -0
- package/dist/core/asyncapi/merge/merge.js.map +1 -0
- package/dist/core/asyncapi/merge/pin.js +107 -0
- package/dist/core/asyncapi/merge/pin.js.map +1 -0
- package/dist/core/asyncapi/model.js +11 -0
- package/dist/core/asyncapi/model.js.map +1 -0
- package/dist/core/asyncapi/producers.js +50 -0
- package/dist/core/asyncapi/producers.js.map +1 -0
- package/dist/core/{asyncapi.js → asyncapi/read.js} +95 -109
- package/dist/core/asyncapi/read.js.map +1 -0
- package/dist/core/brief/brief.js +225 -0
- package/dist/core/brief/brief.js.map +1 -0
- package/dist/core/brief/checks.js +250 -0
- package/dist/core/brief/checks.js.map +1 -0
- package/dist/core/brief/landscape.js +194 -0
- package/dist/core/brief/landscape.js.map +1 -0
- package/dist/core/brief/targets.js +226 -0
- package/dist/core/brief/targets.js.map +1 -0
- package/dist/core/brief/unchecked.js +47 -0
- package/dist/core/brief/unchecked.js.map +1 -0
- package/dist/core/c4/arch.js +282 -0
- package/dist/core/c4/arch.js.map +1 -0
- package/dist/core/c4/likec4.js +88 -0
- package/dist/core/c4/likec4.js.map +1 -0
- package/dist/core/c4/parsed/dynamic-views.js +132 -0
- package/dist/core/c4/parsed/dynamic-views.js.map +1 -0
- package/dist/core/c4/parsed/specification.js +78 -0
- package/dist/core/c4/parsed/specification.js.map +1 -0
- package/dist/core/c4/parsed/values.js +57 -0
- package/dist/core/c4/parsed/values.js.map +1 -0
- package/dist/core/c4/parsed/view-ids.js +75 -0
- package/dist/core/c4/parsed/view-ids.js.map +1 -0
- package/dist/core/c4/project/architecture.js +45 -0
- package/dist/core/c4/project/architecture.js.map +1 -0
- package/dist/core/c4/project/documents.js +58 -0
- package/dist/core/c4/project/documents.js.map +1 -0
- package/dist/core/c4/project/load.js +183 -0
- package/dist/core/c4/project/load.js.map +1 -0
- package/dist/core/c4/resolve/service.js +113 -0
- package/dist/core/c4/resolve/service.js.map +1 -0
- package/dist/core/c4/seed/fleet-file.js +201 -0
- package/dist/core/c4/seed/fleet-file.js.map +1 -0
- package/dist/core/c4/seed/items.js +91 -0
- package/dist/core/c4/seed/items.js.map +1 -0
- package/dist/core/c4/seed/stamp.js +48 -0
- package/dist/core/c4/seed/stamp.js.map +1 -0
- package/dist/core/c4/seed/template.js +109 -0
- package/dist/core/c4/seed/template.js.map +1 -0
- package/dist/core/c4/source-mask.js +82 -0
- package/dist/core/c4/source-mask.js.map +1 -0
- package/dist/core/c4/source-scan.js +227 -0
- package/dist/core/c4/source-scan.js.map +1 -0
- package/dist/core/c4/splice/authored-source.js +101 -0
- package/dist/core/c4/splice/authored-source.js.map +1 -0
- package/dist/core/c4/splice/contract.js +10 -0
- package/dist/core/c4/splice/contract.js.map +1 -0
- package/dist/core/c4/splice/delta-blocks.js +126 -0
- package/dist/core/c4/splice/delta-blocks.js.map +1 -0
- package/dist/core/c4/splice/identity/edges.js +36 -0
- package/dist/core/c4/splice/identity/edges.js.map +1 -0
- package/dist/core/c4/splice/landscape-merge.js +269 -0
- package/dist/core/c4/splice/landscape-merge.js.map +1 -0
- package/dist/core/c4/splice/placement.js +194 -0
- package/dist/core/c4/splice/placement.js.map +1 -0
- package/dist/core/c4/workspace.js +172 -0
- package/dist/core/c4/workspace.js.map +1 -0
- package/dist/core/capabilities/authoring/notes.js +141 -0
- package/dist/core/capabilities/authoring/notes.js.map +1 -0
- package/dist/core/capabilities/capabilities.js +124 -0
- package/dist/core/capabilities/capabilities.js.map +1 -0
- package/dist/core/capabilities/delta/doc.js +50 -0
- package/dist/core/capabilities/delta/doc.js.map +1 -0
- package/dist/core/capabilities/delta/overlay.js +129 -0
- package/dist/core/capabilities/delta/overlay.js.map +1 -0
- package/dist/core/capabilities/delta/summary.js +57 -0
- package/dist/core/capabilities/delta/summary.js.map +1 -0
- package/dist/core/capabilities/delta/tree.js +43 -0
- package/dist/core/capabilities/delta/tree.js.map +1 -0
- package/dist/core/capabilities/delta/uncovered.js +222 -0
- package/dist/core/capabilities/delta/uncovered.js.map +1 -0
- package/dist/core/capabilities/findings.js +342 -0
- package/dist/core/capabilities/findings.js.map +1 -0
- package/dist/core/capabilities/realizes/findings.js +181 -0
- package/dist/core/capabilities/realizes/findings.js.map +1 -0
- package/dist/core/capabilities/realizes/join.js +143 -0
- package/dist/core/capabilities/realizes/join.js.map +1 -0
- package/dist/core/capabilities/rollup.js +199 -0
- package/dist/core/capabilities/rollup.js.map +1 -0
- package/dist/core/capabilities/tree.js +126 -0
- package/dist/core/capabilities/tree.js.map +1 -0
- package/dist/core/capabilities/usecase-join.js +277 -0
- package/dist/core/capabilities/usecase-join.js.map +1 -0
- package/dist/core/coherence/authoring/scaffold.js +177 -0
- package/dist/core/coherence/authoring/scaffold.js.map +1 -0
- package/dist/core/coherence/authoring/sentinels.js +74 -0
- package/dist/core/coherence/authoring/sentinels.js.map +1 -0
- package/dist/core/coherence/coherence.js +251 -0
- package/dist/core/coherence/coherence.js.map +1 -0
- package/dist/core/coherence/declared.js +269 -0
- package/dist/core/coherence/declared.js.map +1 -0
- package/dist/core/coherence/events/declared.js +206 -0
- package/dist/core/coherence/events/declared.js.map +1 -0
- package/dist/core/coherence/events/events.js +204 -0
- package/dist/core/coherence/events/events.js.map +1 -0
- package/dist/core/coherence/events/grades.js +166 -0
- package/dist/core/coherence/events/grades.js.map +1 -0
- package/dist/core/coherence/events/lookups.js +122 -0
- package/dist/core/coherence/events/lookups.js.map +1 -0
- package/dist/core/coherence/events/merged.js +40 -0
- package/dist/core/coherence/events/merged.js.map +1 -0
- package/dist/core/coherence/living.js +198 -0
- package/dist/core/coherence/living.js.map +1 -0
- package/dist/core/coherence/lookups.js +155 -0
- package/dist/core/coherence/lookups.js.map +1 -0
- package/dist/core/coherence/pending.js +39 -0
- package/dist/core/coherence/pending.js.map +1 -0
- package/dist/core/conflict-markers.js +60 -0
- package/dist/core/conflict-markers.js.map +1 -0
- package/dist/core/delta/claims.js +73 -0
- package/dist/core/delta/claims.js.map +1 -0
- package/dist/core/delta/delta.js +296 -0
- package/dist/core/delta/delta.js.map +1 -0
- package/dist/core/delta/document.js +177 -0
- package/dist/core/delta/document.js.map +1 -0
- package/dist/core/delta/scope.js +48 -0
- package/dist/core/delta/scope.js.map +1 -0
- package/dist/core/delta/select.js +237 -0
- package/dist/core/delta/select.js.map +1 -0
- package/dist/core/dependencies/dependencies.js +141 -0
- package/dist/core/dependencies/dependencies.js.map +1 -0
- package/dist/core/dependencies/facts.js +165 -0
- package/dist/core/dependencies/facts.js.map +1 -0
- package/dist/core/dependencies/fanin.js +88 -0
- package/dist/core/dependencies/fanin.js.map +1 -0
- package/dist/core/dependencies/graph.js +106 -0
- package/dist/core/dependencies/graph.js.map +1 -0
- package/dist/core/diff/base-git.js +148 -0
- package/dist/core/diff/base-git.js.map +1 -0
- package/dist/core/diff/base-state.js +209 -0
- package/dist/core/diff/base-state.js.map +1 -0
- package/dist/core/diff/current-state.js +96 -0
- package/dist/core/diff/current-state.js.map +1 -0
- package/dist/core/diff/scope/changed-paths.js +188 -0
- package/dist/core/diff/scope/changed-paths.js.map +1 -0
- package/dist/core/diff/semantic.js +243 -0
- package/dist/core/diff/semantic.js.map +1 -0
- package/dist/core/diff/victims.js +247 -0
- package/dist/core/diff/victims.js.map +1 -0
- package/dist/core/docs.js +7 -64
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor/agents.js +222 -0
- package/dist/core/doctor/agents.js.map +1 -0
- package/dist/core/doctor/config.js +135 -0
- package/dist/core/doctor/config.js.map +1 -0
- package/dist/core/doctor/doctor.js +275 -0
- package/dist/core/doctor/doctor.js.map +1 -0
- package/dist/core/doctor/report.js +13 -0
- package/dist/core/doctor/report.js.map +1 -0
- package/dist/core/doctor/residue.js +110 -0
- package/dist/core/doctor/residue.js.map +1 -0
- package/dist/core/document/apply.js +77 -0
- package/dist/core/document/apply.js.map +1 -0
- package/dist/core/{frontmatter.js → document/frontmatter.js} +48 -2
- package/dist/core/document/frontmatter.js.map +1 -0
- package/dist/core/document/grammar/directives.js +143 -0
- package/dist/core/document/grammar/directives.js.map +1 -0
- package/dist/core/document/parse.js +263 -0
- package/dist/core/document/parse.js.map +1 -0
- package/dist/core/document/scenarios.js +221 -0
- package/dist/core/document/scenarios.js.map +1 -0
- package/dist/core/document/spec.js +241 -0
- package/dist/core/document/spec.js.map +1 -0
- package/dist/core/{config.js → envelope/config.js} +79 -18
- package/dist/core/envelope/config.js.map +1 -0
- package/dist/core/envelope/json.js +175 -0
- package/dist/core/envelope/json.js.map +1 -0
- package/dist/core/{version.js → envelope/version.js} +3 -3
- package/dist/core/envelope/version.js.map +1 -0
- package/dist/core/explain/families.js +175 -0
- package/dist/core/explain/families.js.map +1 -0
- package/dist/core/explain/fix-tables.js +178 -0
- package/dist/core/explain/fix-tables.js.map +1 -0
- package/dist/core/explain/lookup.js +181 -0
- package/dist/core/explain/lookup.js.map +1 -0
- package/dist/core/explain/refusals.js +49 -0
- package/dist/core/explain/refusals.js.map +1 -0
- package/dist/core/explain/terms.js +144 -0
- package/dist/core/explain/terms.js.map +1 -0
- package/dist/core/explore/describe.js +105 -0
- package/dist/core/explore/describe.js.map +1 -0
- package/dist/core/{explore.js → explore/explore.js} +50 -101
- package/dist/core/explore/explore.js.map +1 -0
- package/dist/core/fleet-context.js +120 -78
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/gate/checks.js +201 -0
- package/dist/core/gate/checks.js.map +1 -0
- package/dist/core/gate/partners.js +205 -0
- package/dist/core/gate/partners.js.map +1 -0
- package/dist/core/gate/report.js +47 -0
- package/dist/core/gate/report.js.map +1 -0
- package/dist/core/gate/verification.js +113 -0
- package/dist/core/gate/verification.js.map +1 -0
- package/dist/core/gherkin/digest.js +60 -0
- package/dist/core/gherkin/digest.js.map +1 -0
- package/dist/core/gherkin/emit.js +177 -0
- package/dist/core/gherkin/emit.js.map +1 -0
- package/dist/core/gherkin/read.js +76 -0
- package/dist/core/gherkin/read.js.map +1 -0
- package/dist/core/gherkin/stale.js +234 -0
- package/dist/core/gherkin/stale.js.map +1 -0
- package/dist/core/gherkin/stamp.js +77 -0
- package/dist/core/gherkin/stamp.js.map +1 -0
- package/dist/core/gherkin/steps/catalogue.js +154 -0
- package/dist/core/gherkin/steps/catalogue.js.map +1 -0
- package/dist/core/gherkin/steps/inventory.js +98 -0
- package/dist/core/gherkin/steps/inventory.js.map +1 -0
- package/dist/core/gherkin/steps/phrase.js +72 -0
- package/dist/core/gherkin/steps/phrase.js.map +1 -0
- package/dist/core/glossary/delta.js +106 -0
- package/dist/core/glossary/delta.js.map +1 -0
- package/dist/core/glossary/tree.js +86 -0
- package/dist/core/glossary/tree.js.map +1 -0
- package/dist/core/kernel/concurrency.js.map +1 -0
- package/dist/core/{document-bytes.js → kernel/document-bytes.js} +1 -1
- package/dist/core/kernel/document-bytes.js.map +1 -0
- package/dist/core/kernel/fences.js +42 -0
- package/dist/core/kernel/fences.js.map +1 -0
- package/dist/core/kernel/ids/capability.js +72 -0
- package/dist/core/kernel/ids/capability.js.map +1 -0
- package/dist/core/kernel/ids/dirs.js +33 -0
- package/dist/core/kernel/ids/dirs.js.map +1 -0
- package/dist/core/kernel/ids/feature.js +75 -0
- package/dist/core/kernel/ids/feature.js.map +1 -0
- package/dist/core/kernel/ids/service.js +156 -0
- package/dist/core/kernel/ids/service.js.map +1 -0
- package/dist/core/kernel/ids/subsystem.js +63 -0
- package/dist/core/kernel/ids/subsystem.js.map +1 -0
- package/dist/core/{path-safety.js → kernel/path-safety.js} +18 -4
- package/dist/core/kernel/path-safety.js.map +1 -0
- package/dist/core/kernel/records.js.map +1 -0
- package/dist/core/links/backlinks.js +68 -0
- package/dist/core/links/backlinks.js.map +1 -0
- package/dist/core/links/case.js +74 -0
- package/dist/core/links/case.js.map +1 -0
- package/dist/core/links/corpus.js +104 -0
- package/dist/core/links/corpus.js.map +1 -0
- package/dist/core/links/findings.js +110 -0
- package/dist/core/links/findings.js.map +1 -0
- package/dist/core/links/parse.js +236 -0
- package/dist/core/links/parse.js.map +1 -0
- package/dist/core/mcp/argv.js +111 -0
- package/dist/core/mcp/argv.js.map +1 -0
- package/dist/core/mcp/author-tools.js +72 -0
- package/dist/core/mcp/author-tools.js.map +1 -0
- package/dist/core/mcp/framing.js +46 -0
- package/dist/core/mcp/framing.js.map +1 -0
- package/dist/core/mcp/protocol.js +280 -0
- package/dist/core/mcp/protocol.js.map +1 -0
- package/dist/core/mcp/tools.js +314 -0
- package/dist/core/mcp/tools.js.map +1 -0
- package/dist/core/obligations/obligations.js +106 -0
- package/dist/core/obligations/obligations.js.map +1 -0
- package/dist/core/openapi/baseline/gate.js +166 -0
- package/dist/core/openapi/baseline/gate.js.map +1 -0
- package/dist/core/openapi/baseline/plan.js +112 -0
- package/dist/core/openapi/baseline/plan.js.map +1 -0
- package/dist/core/openapi/baseline/record.js +175 -0
- package/dist/core/openapi/baseline/record.js.map +1 -0
- package/dist/core/openapi/depth.js +122 -0
- package/dist/core/openapi/depth.js.map +1 -0
- package/dist/core/openapi/digest.js +189 -0
- package/dist/core/openapi/digest.js.map +1 -0
- package/dist/core/{openapi.js → openapi/doc.js} +44 -106
- package/dist/core/openapi/doc.js.map +1 -0
- package/dist/core/openapi/merge/components.js +234 -0
- package/dist/core/openapi/merge/components.js.map +1 -0
- package/dist/core/openapi/merge/error.js +26 -0
- package/dist/core/openapi/merge/error.js.map +1 -0
- package/dist/core/openapi/merge/markers.js +208 -0
- package/dist/core/openapi/merge/markers.js.map +1 -0
- package/dist/core/openapi/merge/merge.js +256 -0
- package/dist/core/openapi/merge/merge.js.map +1 -0
- package/dist/core/openapi/merge/pin.js +128 -0
- package/dist/core/openapi/merge/pin.js.map +1 -0
- package/dist/core/openspec/decide/capabilities.js +244 -0
- package/dist/core/openspec/decide/capabilities.js.map +1 -0
- package/dist/core/openspec/decide/dispositions.js +63 -0
- package/dist/core/openspec/decide/dispositions.js.map +1 -0
- package/dist/core/openspec/decide/renames.js +179 -0
- package/dist/core/openspec/decide/renames.js.map +1 -0
- package/dist/core/openspec/decide/verdict.js +84 -0
- package/dist/core/openspec/decide/verdict.js.map +1 -0
- package/dist/core/openspec/inventory.js +221 -0
- package/dist/core/openspec/inventory.js.map +1 -0
- package/dist/core/openspec/model/mapping.js +76 -0
- package/dist/core/openspec/model/mapping.js.map +1 -0
- package/dist/core/openspec/model/model.js +65 -0
- package/dist/core/openspec/model/model.js.map +1 -0
- package/dist/core/openspec/scan/changes.js +235 -0
- package/dist/core/openspec/scan/changes.js.map +1 -0
- package/dist/core/openspec/scan/config.js +200 -0
- package/dist/core/openspec/scan/config.js.map +1 -0
- package/dist/core/openspec/scan/shape.js +260 -0
- package/dist/core/openspec/scan/shape.js.map +1 -0
- package/dist/core/openspec/scan/walk.js +133 -0
- package/dist/core/openspec/scan/walk.js.map +1 -0
- package/dist/core/openspec/scan/workspace.js +71 -0
- package/dist/core/openspec/scan/workspace.js.map +1 -0
- package/dist/core/owners/codeowners.js +141 -0
- package/dist/core/owners/codeowners.js.map +1 -0
- package/dist/core/pack/features.js +120 -0
- package/dist/core/pack/features.js.map +1 -0
- package/dist/core/pack/joins.js +112 -0
- package/dist/core/pack/joins.js.map +1 -0
- package/dist/core/pack/living.js +118 -0
- package/dist/core/pack/living.js.map +1 -0
- package/dist/core/pack/pack.js +76 -0
- package/dist/core/pack/pack.js.map +1 -0
- package/dist/core/permissions/permissions.js +87 -0
- package/dist/core/permissions/permissions.js.map +1 -0
- package/dist/core/projection/api.js +84 -0
- package/dist/core/projection/api.js.map +1 -0
- package/dist/core/projection/arch-slice.js +63 -0
- package/dist/core/projection/arch-slice.js.map +1 -0
- package/dist/core/projection/events.js +34 -0
- package/dist/core/projection/events.js.map +1 -0
- package/dist/core/provenance/findings.js +208 -0
- package/dist/core/provenance/findings.js.map +1 -0
- package/dist/core/provenance/git.js +225 -0
- package/dist/core/provenance/git.js.map +1 -0
- package/dist/core/provenance/gitq/moves.js +182 -0
- package/dist/core/provenance/gitq/moves.js.map +1 -0
- package/dist/core/provenance/gitq/vouched-ancestor.js +179 -0
- package/dist/core/provenance/gitq/vouched-ancestor.js.map +1 -0
- package/dist/core/provenance/sample/scope.js +238 -0
- package/dist/core/provenance/sample/scope.js.map +1 -0
- package/dist/core/provenance/sample/sections.js +61 -0
- package/dist/core/provenance/sample/sections.js.map +1 -0
- package/dist/core/provenance/sources.js +263 -0
- package/dist/core/provenance/sources.js.map +1 -0
- package/dist/core/provenance/stamp.js +232 -0
- package/dist/core/provenance/stamp.js.map +1 -0
- package/dist/core/provenance/walk.js +157 -0
- package/dist/core/provenance/walk.js.map +1 -0
- package/dist/core/repo/authored/paths.js +192 -0
- package/dist/core/repo/authored/paths.js.map +1 -0
- package/dist/core/repo/entries.js +126 -0
- package/dist/core/repo/entries.js.map +1 -0
- package/dist/core/repo/paths.js +236 -0
- package/dist/core/repo/paths.js.map +1 -0
- package/dist/core/repo/repo.js +248 -0
- package/dist/core/repo/repo.js.map +1 -0
- package/dist/core/repo/service-target.js +153 -0
- package/dist/core/repo/service-target.js.map +1 -0
- package/dist/core/repo/state.js +58 -0
- package/dist/core/repo/state.js.map +1 -0
- package/dist/core/repo/tree/find.js +52 -0
- package/dist/core/repo/tree/find.js.map +1 -0
- package/dist/core/repo/tree/fs.js +64 -0
- package/dist/core/repo/tree/fs.js.map +1 -0
- package/dist/core/repo/tree/marker.js +61 -0
- package/dist/core/repo/tree/marker.js.map +1 -0
- package/dist/core/repo/tree/views.js +172 -0
- package/dist/core/repo/tree/views.js.map +1 -0
- package/dist/core/repo/tree/walk.js +234 -0
- package/dist/core/repo/tree/walk.js.map +1 -0
- package/dist/core/results.js +99 -2
- package/dist/core/results.js.map +1 -1
- package/dist/core/scaffold/landscape.js +131 -0
- package/dist/core/scaffold/landscape.js.map +1 -0
- package/dist/core/scaffold/readme.js +84 -0
- package/dist/core/scaffold/readme.js.map +1 -0
- package/dist/core/staging/commit.js +241 -0
- package/dist/core/staging/commit.js.map +1 -0
- package/dist/core/staging/interrupted.js +96 -0
- package/dist/core/staging/interrupted.js.map +1 -0
- package/dist/core/staging/lock.js +206 -0
- package/dist/core/staging/lock.js.map +1 -0
- package/dist/core/staging/recovery/finding.js +35 -0
- package/dist/core/staging/recovery/finding.js.map +1 -0
- package/dist/core/staging/recovery/intent.js +98 -0
- package/dist/core/staging/recovery/intent.js.map +1 -0
- package/dist/core/staging/recovery/recover.js +126 -0
- package/dist/core/staging/recovery/recover.js.map +1 -0
- package/dist/core/staging/recovery/residue.js +62 -0
- package/dist/core/staging/recovery/residue.js.map +1 -0
- package/dist/core/staging/snapshot.js +202 -0
- package/dist/core/staging/snapshot.js.map +1 -0
- package/dist/core/staging/txn/forward.js +171 -0
- package/dist/core/staging/txn/forward.js.map +1 -0
- package/dist/core/staging/txn/journal.js +139 -0
- package/dist/core/staging/txn/journal.js.map +1 -0
- package/dist/core/staging/txn/transaction.js +75 -0
- package/dist/core/staging/txn/transaction.js.map +1 -0
- package/dist/core/staging/writes.js +113 -0
- package/dist/core/staging/writes.js.map +1 -0
- package/dist/core/status/actions/execution.js +49 -0
- package/dist/core/status/actions/execution.js.map +1 -0
- package/dist/core/status/contracts.js +102 -0
- package/dist/core/status/contracts.js.map +1 -0
- package/dist/core/status/feature/artifacts.js +171 -0
- package/dist/core/status/feature/artifacts.js.map +1 -0
- package/dist/core/status/feature/feature.js +207 -0
- package/dist/core/status/feature/feature.js.map +1 -0
- package/dist/core/status/feature/next.js +150 -0
- package/dist/core/status/feature/next.js.map +1 -0
- package/dist/core/status/feature/state.js +21 -0
- package/dist/core/status/feature/state.js.map +1 -0
- package/dist/core/status/fleet/fleet.js +187 -0
- package/dist/core/status/fleet/fleet.js.map +1 -0
- package/dist/core/status/fleet/next.js +207 -0
- package/dist/core/status/fleet/next.js.map +1 -0
- package/dist/core/status/interrupted.js +97 -0
- package/dist/core/status/interrupted.js.map +1 -0
- package/dist/core/status/report.js +31 -0
- package/dist/core/status/report.js.map +1 -0
- package/dist/core/status/scan.js +41 -0
- package/dist/core/status/scan.js.map +1 -0
- package/dist/core/status/verification.js +201 -0
- package/dist/core/status/verification.js.map +1 -0
- package/dist/core/usecases/capability.js +169 -0
- package/dist/core/usecases/capability.js.map +1 -0
- package/dist/core/usecases/delta/claims.js +116 -0
- package/dist/core/usecases/delta/claims.js.map +1 -0
- package/dist/core/usecases/delta/flows.js +103 -0
- package/dist/core/usecases/delta/flows.js.map +1 -0
- package/dist/core/usecases/delta/overlay.js +235 -0
- package/dist/core/usecases/delta/overlay.js.map +1 -0
- package/dist/core/usecases/fleet.js +137 -0
- package/dist/core/usecases/fleet.js.map +1 -0
- package/dist/core/usecases/operations.js +111 -0
- package/dist/core/usecases/operations.js.map +1 -0
- package/dist/core/usecases/place.js +44 -0
- package/dist/core/usecases/place.js.map +1 -0
- package/dist/core/usecases/touch.js +101 -0
- package/dist/core/usecases/touch.js.map +1 -0
- package/dist/core/verify/answers.js +173 -0
- package/dist/core/verify/answers.js.map +1 -0
- package/dist/core/verify/build.js +150 -0
- package/dist/core/verify/build.js.map +1 -0
- package/dist/core/verify/checklist.js +201 -0
- package/dist/core/verify/checklist.js.map +1 -0
- package/dist/core/verify/claims/identity.js +61 -0
- package/dist/core/verify/claims/identity.js.map +1 -0
- package/dist/core/verify/cross/diff.js +97 -0
- package/dist/core/verify/cross/diff.js.map +1 -0
- package/dist/core/verify/evidence/contract.js +169 -0
- package/dist/core/verify/evidence/contract.js.map +1 -0
- package/dist/core/verify/file.js +257 -0
- package/dist/core/verify/file.js.map +1 -0
- package/dist/core/verify/pins/lint.js +157 -0
- package/dist/core/verify/pins/lint.js.map +1 -0
- package/dist/core/verify/pins/pin.js +95 -0
- package/dist/core/verify/pins/pin.js.map +1 -0
- package/dist/core/verify/record.js +142 -0
- package/dist/core/verify/record.js.map +1 -0
- package/dist/core/verify/store/commit.js +89 -0
- package/dist/core/verify/store/commit.js.map +1 -0
- package/dist/core/verify/store/render.js +57 -0
- package/dist/core/verify/store/render.js.map +1 -0
- package/dist/core/vocabulary/health.js +91 -0
- package/dist/core/vocabulary/health.js.map +1 -0
- package/dist/core/vocabulary/issue.js +55 -0
- package/dist/core/vocabulary/issue.js.map +1 -0
- package/dist/core/vocabulary/maturity.js +145 -0
- package/dist/core/vocabulary/maturity.js.map +1 -0
- package/dist/core/{report.js → vocabulary/report.js} +30 -2
- package/dist/core/vocabulary/report.js.map +1 -0
- package/dist/core/vocabulary/steps.js +266 -0
- package/dist/core/vocabulary/steps.js.map +1 -0
- package/dist/core/workspace/discover.js +239 -0
- package/dist/core/workspace/discover.js.map +1 -0
- package/dist/core/workspace/render.js +52 -0
- package/dist/core/workspace/render.js.map +1 -0
- package/examples/README.md +189 -0
- package/examples/docs/architecture/adrs/0001-transactional-outbox.md +33 -0
- package/examples/docs/architecture/capabilities.yaml +31 -0
- package/examples/docs/architecture/landscape.likec4 +280 -0
- package/examples/docs/architecture/obligations.yaml +23 -0
- package/examples/docs/architecture/permissions.yaml +50 -0
- package/examples/docs/architecture/subsystems.likec4 +11 -0
- package/examples/docs/architecture/usecases/order-notification.likec4 +65 -0
- package/examples/docs/capabilities/checkout/spec.md +55 -0
- package/examples/docs/capabilities/identity/tokens/spec.md +42 -0
- package/examples/docs/capabilities/order-notifications/spec.md +43 -0
- package/examples/docs/features/FEAT-101-payment-splitting/adrs/0001-dedicated-split-service.md +18 -0
- package/examples/docs/features/FEAT-101-payment-splitting/delta.likec4 +94 -0
- package/examples/docs/features/FEAT-101-payment-splitting/intent.md +44 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/checkout-web/spec.md +21 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-service/asyncapi.yaml +129 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-service/spec.md +36 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/arch.spec.md +40 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/openapi.yaml +116 -0
- package/examples/docs/features/FEAT-101-payment-splitting/specs/payment-split-service/spec.md +55 -0
- package/examples/docs/features/FEAT-112-retire-order-v1/intent.md +35 -0
- package/examples/docs/features/FEAT-112-retire-order-v1/specs/order-service/openapi.yaml +28 -0
- package/examples/docs/features/FEAT-112-retire-order-v1/specs/order-service/spec.md +17 -0
- package/examples/docs/features/archive/FEAT-088-refunds/.loam-before/files/architecture/landscape.likec4 +253 -0
- package/examples/docs/features/archive/FEAT-088-refunds/.loam-before/files/services/order-service/spec.md +121 -0
- package/examples/docs/features/archive/FEAT-088-refunds/.loam-before/files/services/payment-service/openapi.yaml +111 -0
- package/examples/docs/features/archive/FEAT-088-refunds/.loam-before/files/services/payment-service/spec.md +58 -0
- package/examples/docs/features/archive/FEAT-088-refunds/.loam-before/manifest.json +32 -0
- package/examples/docs/features/archive/FEAT-088-refunds/adrs/0001-refunds-stay-in-payment-service.md +26 -0
- package/examples/docs/features/archive/FEAT-088-refunds/delta.likec4 +28 -0
- package/examples/docs/features/archive/FEAT-088-refunds/intent.md +26 -0
- package/examples/docs/features/archive/FEAT-088-refunds/specs/order-service/spec.md +37 -0
- package/examples/docs/features/archive/FEAT-088-refunds/specs/payment-service/openapi.yaml +70 -0
- package/examples/docs/features/archive/FEAT-088-refunds/specs/payment-service/spec.md +23 -0
- package/examples/docs/features/archive/FEAT-088-refunds/verification.yaml +76 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/files/architecture/landscape.likec4 +275 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/files/services/payment-service/asyncapi.yaml +96 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/files/services/payment-service/spec.md +82 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/files/services/platform/notification-service/asyncapi.yaml +164 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/files/services/platform/notification-service/spec.md +91 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/.loam-before/manifest.json +46 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/delta.likec4 +36 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/intent.md +22 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/scenario-report.json +10 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/specs/notification-service/asyncapi.yaml +208 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/specs/notification-service/spec.md +24 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/specs/payment-service/asyncapi.yaml +137 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/specs/payment-service/spec.md +31 -0
- package/examples/docs/features/archive/FEAT-120-refund-notification/verification.yaml +84 -0
- package/examples/docs/glossary/order.md +15 -0
- package/examples/docs/glossary/payments/authorization.md +14 -0
- package/examples/docs/likec4.config.json +9 -0
- package/examples/docs/loam.json +3 -0
- package/examples/docs/services/checkout-web/model.likec4 +109 -0
- package/examples/docs/services/checkout-web/spec.md +91 -0
- package/examples/docs/services/checkout-web/ui/pages/checkout.page.yaml +40 -0
- package/examples/docs/services/checkout-web/ui/pages/order-status.page.yaml +24 -0
- package/examples/docs/services/checkout-web/ui/pages/split-payment.page.yaml +26 -0
- package/examples/docs/services/order-service/adrs/0001-orchestrated-order-lifecycle.md +52 -0
- package/examples/docs/services/order-service/arch.spec.md +127 -0
- package/examples/docs/services/order-service/asyncapi.yaml +108 -0
- package/examples/docs/services/order-service/health.yaml +57 -0
- package/examples/docs/services/order-service/model.likec4 +138 -0
- package/examples/docs/services/order-service/openapi.yaml +257 -0
- package/examples/docs/services/order-service/runbook.md +62 -0
- package/examples/docs/services/order-service/spec.md +134 -0
- package/examples/docs/services/payment-service/adrs/0001-payments-outbox.md +18 -0
- package/examples/docs/services/payment-service/adrs/0002-idempotency-keys-on-authorization.md +27 -0
- package/examples/docs/services/payment-service/arch.spec.md +73 -0
- package/examples/docs/services/payment-service/asyncapi.yaml +130 -0
- package/examples/docs/services/payment-service/health.yaml +39 -0
- package/examples/docs/services/payment-service/model.likec4 +67 -0
- package/examples/docs/services/payment-service/openapi.yaml +151 -0
- package/examples/docs/services/payment-service/runbook.md +37 -0
- package/examples/docs/services/payment-service/spec.md +88 -0
- package/examples/docs/services/platform/identity-service/adrs/0001-token-introspection-over-local-validation.md +60 -0
- package/examples/docs/services/platform/identity-service/arch.spec.md +117 -0
- package/examples/docs/services/platform/identity-service/health.yaml +70 -0
- package/examples/docs/services/platform/identity-service/model.likec4 +83 -0
- package/examples/docs/services/platform/identity-service/openapi.yaml +197 -0
- package/examples/docs/services/platform/identity-service/runbook.md +95 -0
- package/examples/docs/services/platform/identity-service/spec.md +119 -0
- package/examples/docs/services/platform/notification-service/asyncapi.yaml +199 -0
- package/examples/docs/services/platform/notification-service/health.yaml +35 -0
- package/examples/docs/services/platform/notification-service/model.likec4 +118 -0
- package/examples/docs/services/platform/notification-service/runbook.md +48 -0
- package/examples/docs/services/platform/notification-service/spec.md +112 -0
- package/examples/docs/services/platform/subsystem.yaml +2 -0
- package/examples/fleet.yaml +57 -0
- package/package.json +19 -6
- package/dist/commands/adopt.js +0 -240
- package/dist/commands/adopt.js.map +0 -1
- package/dist/commands/archive.js +0 -1313
- package/dist/commands/archive.js.map +0 -1
- package/dist/commands/delta.js +0 -388
- package/dist/commands/delta.js.map +0 -1
- package/dist/commands/docs-repo-gate.js.map +0 -1
- package/dist/commands/format.js +0 -17
- package/dist/commands/format.js.map +0 -1
- package/dist/commands/gherkin.js +0 -309
- package/dist/commands/gherkin.js.map +0 -1
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/list.js +0 -295
- package/dist/commands/list.js.map +0 -1
- package/dist/commands/migrate-openspec.js +0 -950
- package/dist/commands/migrate-openspec.js.map +0 -1
- package/dist/commands/new.js +0 -413
- package/dist/commands/new.js.map +0 -1
- package/dist/commands/rebase.js +0 -356
- package/dist/commands/rebase.js.map +0 -1
- package/dist/commands/show.js.map +0 -1
- package/dist/commands/status.js +0 -262
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/unarchive.js.map +0 -1
- package/dist/commands/validate.js +0 -1773
- package/dist/commands/validate.js.map +0 -1
- package/dist/commands/verify.js +0 -775
- package/dist/commands/verify.js.map +0 -1
- package/dist/commands/vouch.js +0 -421
- package/dist/commands/vouch.js.map +0 -1
- package/dist/core/agent.js +0 -2173
- package/dist/core/agent.js.map +0 -1
- package/dist/core/agents-stamp.js.map +0 -1
- package/dist/core/arch.js +0 -130
- package/dist/core/arch.js.map +0 -1
- package/dist/core/asyncapi.js.map +0 -1
- package/dist/core/brief.js +0 -755
- package/dist/core/brief.js.map +0 -1
- package/dist/core/coherence.js +0 -577
- package/dist/core/coherence.js.map +0 -1
- package/dist/core/concurrency.js.map +0 -1
- package/dist/core/config.js.map +0 -1
- package/dist/core/delta.js +0 -476
- package/dist/core/delta.js.map +0 -1
- package/dist/core/dependencies.js +0 -356
- package/dist/core/dependencies.js.map +0 -1
- package/dist/core/doctor.js +0 -596
- package/dist/core/doctor.js.map +0 -1
- package/dist/core/document-bytes.js.map +0 -1
- package/dist/core/explore.js.map +0 -1
- package/dist/core/frontmatter.js.map +0 -1
- package/dist/core/gherkin.js +0 -463
- package/dist/core/gherkin.js.map +0 -1
- package/dist/core/health.js +0 -67
- package/dist/core/health.js.map +0 -1
- package/dist/core/ids.js +0 -122
- package/dist/core/ids.js.map +0 -1
- package/dist/core/issue.js +0 -9
- package/dist/core/issue.js.map +0 -1
- package/dist/core/json.js +0 -74
- package/dist/core/json.js.map +0 -1
- package/dist/core/likec4.js +0 -489
- package/dist/core/likec4.js.map +0 -1
- package/dist/core/maturity.js +0 -66
- package/dist/core/maturity.js.map +0 -1
- package/dist/core/openapi-merge.js +0 -552
- package/dist/core/openapi-merge.js.map +0 -1
- package/dist/core/openapi.js.map +0 -1
- package/dist/core/openspec-inventory.js +0 -1440
- package/dist/core/openspec-inventory.js.map +0 -1
- package/dist/core/path-safety.js.map +0 -1
- package/dist/core/provenance.js +0 -787
- package/dist/core/provenance.js.map +0 -1
- package/dist/core/records.js.map +0 -1
- package/dist/core/repo.js +0 -438
- package/dist/core/repo.js.map +0 -1
- package/dist/core/report.js.map +0 -1
- package/dist/core/spec.js +0 -505
- package/dist/core/spec.js.map +0 -1
- package/dist/core/staging.js +0 -890
- package/dist/core/staging.js.map +0 -1
- package/dist/core/status.js +0 -1132
- package/dist/core/status.js.map +0 -1
- package/dist/core/steps.js +0 -72
- package/dist/core/steps.js.map +0 -1
- package/dist/core/verify.js +0 -807
- package/dist/core/verify.js.map +0 -1
- package/dist/core/version.js.map +0 -1
- /package/dist/core/{concurrency.js → kernel/concurrency.js} +0 -0
- /package/dist/core/{records.js → kernel/records.js} +0 -0
package/SCHEMA.md
CHANGED
|
@@ -1,25 +1,56 @@
|
|
|
1
1
|
# loam docs-repo schema
|
|
2
2
|
|
|
3
|
-
This documents the layout of the **docs repo** — the single shared source of truth
|
|
3
|
+
This documents the layout of the **docs repo** — the single shared source of truth for the governed
|
|
4
|
+
system that `loam` reads and writes. A runnable distributed example lives in the repository under
|
|
5
|
+
[`examples/docs/`](https://github.com/ybotok/loam/tree/main/examples/docs).
|
|
4
6
|
|
|
5
|
-
Everything here is plain files. `loam` derives
|
|
7
|
+
Everything here is plain files. `loam` derives **projections** and indexes from them; delete `loam`
|
|
8
|
+
and the docs remain.
|
|
6
9
|
|
|
7
10
|
## Layer stack
|
|
8
11
|
|
|
9
|
-
C4 is the center. Each artifact is a **source** (authored), a **hybrid** (agent-written at
|
|
12
|
+
C4 is the center. Each artifact is a **source** (authored), a **hybrid** (agent-written at
|
|
13
|
+
bootstrap, authored forward), or a **derived** view (generated from the model + spine).
|
|
10
14
|
|
|
11
15
|
```
|
|
12
16
|
presentation │ UI page-prototypes ─(consume)─► endpoints spec = source · proto = derived [later]
|
|
13
17
|
behavior │ gherkin scenarios ──(govern)──► pages source
|
|
14
18
|
arch behavior│ arch.spec.md ──(covers)──► C4 · health source
|
|
15
19
|
contract │ OpenAPI ◄──(detail of C4 "exposes/calls") hybrid
|
|
16
|
-
async │ AsyncAPI ◄──(detail of C4 "publishes/consumes") hybrid
|
|
17
|
-
|
|
20
|
+
async │ AsyncAPI ◄──(detail of C4 "publishes/consumes") hybrid
|
|
21
|
+
authorization│ permissions.yaml ◄──(required by)── requirements source [system vocabulary]
|
|
22
|
+
capability │ capabilities/<cap>/spec.md + capabilities.yaml source [business tree + vocabulary, opt-in]
|
|
23
|
+
│ ◄──(realized by)── requirements
|
|
24
|
+
structure │ C4 model — governed boundaries and relationships source (adopt-seeded)
|
|
18
25
|
ops / why │ ADR · runbook · health source
|
|
19
26
|
truth │ code ground truth
|
|
20
27
|
```
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
The artifacts are wired by a family of explicit joins rather than filenames that merely look
|
|
30
|
+
related. The canonical join table below is the contract; `loam validate` grades each mechanically.
|
|
31
|
+
|
|
32
|
+
## Topology and the frozen `service` term
|
|
33
|
+
|
|
34
|
+
`service` is loam's frozen CLI, path and finding vocabulary for **one governed implementation
|
|
35
|
+
boundary**. It does not assert that the boundary is a network service or a microservice. A boundary
|
|
36
|
+
is the unit whose code, living requirements, contracts, provenance and verification evidence are
|
|
37
|
+
reviewed together and filed under one `services/<id>/` directory.
|
|
38
|
+
|
|
39
|
+
- A **modular monolith** normally has one service id. Its bound C4 root contains nested modules or
|
|
40
|
+
components; architecture requirements can name those elements and their edges with `Covers:`, and
|
|
41
|
+
feature deltas can change them without turning them into fictional services.
|
|
42
|
+
- A **distributed system** normally has several service ids. Relationships between their bound
|
|
43
|
+
roots or descendants can join to OpenAPI operations, AsyncAPI messages and cross-boundary use
|
|
44
|
+
cases.
|
|
45
|
+
- A **hybrid** uses both forms in one landscape. The nearest bound ancestor decides which service
|
|
46
|
+
owns a nested element, so C4 depth never decides topology on its own.
|
|
47
|
+
|
|
48
|
+
The boundary is also the aggregation level of the existing fleet reports: maturity, dependency
|
|
49
|
+
order and the scorecard do not mint independent deployment states for nested modules. A module that
|
|
50
|
+
needs its own repository binding, contracts, evidence and deploy status is a separate governed
|
|
51
|
+
boundary; one that does not stays an element inside its application. The schema therefore broadens
|
|
52
|
+
no command or path and weakens no service-level check — it states what the resolver and loam's own
|
|
53
|
+
single-binary self-model already do.
|
|
23
54
|
|
|
24
55
|
## Layout
|
|
25
56
|
|
|
@@ -29,10 +60,18 @@ docs/
|
|
|
29
60
|
loam.json {"docsDir": "."} — makes the repo self-describing [init --create]
|
|
30
61
|
likec4.config.json one LikeC4 project, scoped to architecture/ [init --create]
|
|
31
62
|
architecture/
|
|
32
|
-
landscape.likec4 global C4 (
|
|
63
|
+
landscape.likec4 global C4 (system landscape)
|
|
64
|
+
subsystems.likec4 one view per subsystem, views only [GENERATED: loam subsystem sync]
|
|
65
|
+
permissions.yaml system-wide authorization vocabulary [authored, opt-in]
|
|
66
|
+
capabilities.yaml system-wide capability vocabulary [authored, opt-in]
|
|
67
|
+
obligations.yaml system-wide architectural obligations [authored, opt-in]
|
|
68
|
+
adrs/NNNN-*.md SYSTEM-level MADR decisions [authored, opt-in]
|
|
33
69
|
landscape.health.yaml composed health model [derived, later]
|
|
34
|
-
|
|
35
|
-
|
|
70
|
+
capabilities/<cap>/ the AUTHORED business tree; nesting spelled by the tree [authored, opt-in]
|
|
71
|
+
spec.md narrative, then `## Requirements` — `Requirement-ID:` REQUIRED
|
|
72
|
+
glossary/<term>.md the system's domain vocabulary, one file per term; nesting allowed [authored, opt-in]
|
|
73
|
+
services/<svc>/ at the root (unfiled), or inside subsystem directories at any depth
|
|
74
|
+
model.likec4 boundary C4 (containers/components) [adopt]
|
|
36
75
|
spec.md living requirements (Requirement/Scenario) [adopt]
|
|
37
76
|
arch.spec.md living ARCHITECTURE requirements (outbox, retries, alerts; Covers:) [authored]
|
|
38
77
|
openapi.yaml API contract [adopt / authored]
|
|
@@ -40,14 +79,18 @@ docs/
|
|
|
40
79
|
adrs/NNNN-*.md MADR decisions [adopt-seed / authored]
|
|
41
80
|
runbook.md operational runbook [adopt-draft / authored]
|
|
42
81
|
health.yaml SLI/SLO, checks, critical deps [authored]
|
|
82
|
+
steps.yaml the step phrases this suite defines [authored, opt-in]
|
|
43
83
|
ui/pages/*.page.yaml page-specs (UI services) [later]
|
|
44
|
-
flows/ interaction flows -> sequence views [later]
|
|
45
84
|
features/<FEAT>/
|
|
46
85
|
intent.md business intent / proposal (why) [authored]
|
|
47
86
|
delta.likec4 C4 delta (architecture) [authored]
|
|
48
87
|
specs/<svc>/spec.md requirement delta (ADDED/MODIFIED/REMOVED + scenarios) [authored]
|
|
49
88
|
specs/<svc>/arch.spec.md architectural requirement delta, same algebra [authored]
|
|
50
89
|
specs/<svc>/openapi.yaml the endpoints this feature adds [authored]
|
|
90
|
+
specs/<svc>/asyncapi.yaml the event-contract delta, slot-pinned [authored]
|
|
91
|
+
capabilities/<cap>/spec.md capability delta, same requirement grammar [authored]
|
|
92
|
+
glossary/<term>.md a word this feature brings with it; create-only [authored]
|
|
93
|
+
usecases/<name>.likec4 a FLOW this feature brings; views-only, create-only [authored]
|
|
51
94
|
adrs/NNNN-*.md feature-level decisions [authored]
|
|
52
95
|
verification.yaml the done-check: claims + verdicts + evidence, one attestation per service [loam verify --service <id> --record]
|
|
53
96
|
features/archive/<FEAT>/ a shipped change — the same files, plus:
|
|
@@ -56,294 +99,2038 @@ docs/
|
|
|
56
99
|
.loam-commit transient: the commit intent journal [archive/unarchive only]
|
|
57
100
|
```
|
|
58
101
|
|
|
59
|
-
|
|
102
|
+
There is no `flows/` tree, and there will not be one. A cross-service **interaction flow** is a
|
|
103
|
+
`dynamic view` under `architecture/` — the one place that already holds every cross-service edge —
|
|
104
|
+
tagged with the capability it realizes. A flow that spans services does not belong under one
|
|
105
|
+
service's directory, and the view's steps join to the landscape's own relationships, which is what
|
|
106
|
+
makes the flow checkable rather than merely drawn.
|
|
107
|
+
|
|
108
|
+
A feature **brings** one the way it brings every other axis: `features/<FEAT>/usecases/<name>.likec4`
|
|
109
|
+
is a views-only document that `loam archive` copies into `architecture/usecases/` and
|
|
110
|
+
`loam unarchive` takes back. Create-only, and graded before the copy against the map the same merge
|
|
111
|
+
would leave behind — so a flow may draw a hop into a service the feature's own `delta.likec4` adds,
|
|
112
|
+
and a hop into an element the merge does NOT land is refused at plan time rather than archived into
|
|
113
|
+
a map the next reader's `loam validate --all` fails on.
|
|
114
|
+
|
|
115
|
+
## Subsystems — a navigable tree under `services/`
|
|
116
|
+
|
|
117
|
+
`services/` may be one flat level (the compatibility case, and forever legal) or an unbounded tree
|
|
118
|
+
of **subsystem** directories grouping the service directories. The whole design turns on one
|
|
119
|
+
invariant: **a service id is the leaf directory name, and placement is never part of any identity.**
|
|
120
|
+
`loam.json`'s `service`, `metadata { service }`, spec frontmatter, `features/<FEAT>/specs/<svc>/`
|
|
121
|
+
and every digest are byte-identical before and after a service moves; `--service` never takes a
|
|
122
|
+
path, and no `subsystem` field exists in any frontmatter. The tree exists so a human can find a
|
|
123
|
+
service and so views can be scoped — it carries **no policy**, and nothing branches on it.
|
|
124
|
+
|
|
125
|
+
A directory under `services/` is classified **three ways, never two**:
|
|
126
|
+
|
|
127
|
+
1. holding `subsystem.yaml` it is a **subsystem**, and is walked;
|
|
128
|
+
2. holding any service artifact (`model.likec4`, `spec.md`, `arch.spec.md`, `openapi.yaml`,
|
|
129
|
+
`asyncapi.yaml`, `runbook.md`, `health.yaml`, `adrs/`) it is a **service**, and is not walked
|
|
130
|
+
deeper;
|
|
131
|
+
3. holding neither while containing subdirectories it is an **error** (`subsystem.unmarked`) naming
|
|
132
|
+
every service found beneath it — which stays enumerated. The third branch exists because two-way
|
|
133
|
+
classification loses services silently on an ordinary clean merge: one branch deletes an emptied
|
|
134
|
+
subsystem's marker while another moves a service in, and the group directory would otherwise be
|
|
135
|
+
read as one empty service. The fleet is never reported smaller than it is.
|
|
136
|
+
|
|
137
|
+
`subsystem.yaml` is a **marker, not a manifest**:
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
title: Payments # optional
|
|
141
|
+
description: Money movement # optional
|
|
142
|
+
owner: payments-team # optional
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
An empty file is a valid marker — presence classifies; content is metadata. It **never** lists
|
|
146
|
+
members: the directory itself is the membership record, kept exactly once. A `members` key is
|
|
147
|
+
`subsystem.invalid`; a marker beside service artifacts is `subsystem.marker-misplaced` (the
|
|
148
|
+
directory stays a service); an unreadable marker still classifies its directory as a subsystem and
|
|
149
|
+
reports exactly one `subsystem.invalid`.
|
|
150
|
+
|
|
151
|
+
Names share **one flat namespace**: every subsystem name and service id, unique across the whole
|
|
152
|
+
tree at any depth, never colliding with each other (`subsystem.name-collision` names every
|
|
153
|
+
claimant). Subsystem names take the service-id grammar (`subsystem.name-invalid` otherwise); depth
|
|
154
|
+
is unbounded and is the author's problem, not the tool's. A service directly under `services/` is
|
|
155
|
+
**unfiled** — permanent, normal, and never a finding; `loam list` carries the count
|
|
156
|
+
(`unfiledServices`, plus `services[].subsystem` and `subsystems[]`), and an empty subsystem is
|
|
157
|
+
legal, since `loam subsystem new` must be usable before anything moves in.
|
|
158
|
+
|
|
159
|
+
The tree is mirrored into **one generated file, never into the authored landscape**:
|
|
160
|
+
`architecture/subsystems.likec4` holds LikeC4 **views only** — no model, no tags, no `specification`
|
|
161
|
+
— one view per subsystem, transitively enumerating the services beneath it as `include` lines naming
|
|
162
|
+
landscape *element* ids (resolved through the same `metadata { service }`-then-title join every
|
|
163
|
+
check uses; a member nothing models is omitted, which `landscape.service-unmodelled` already
|
|
164
|
+
reports). The output is deterministic and line-oriented — subsystems sorted by path, members by id,
|
|
165
|
+
one `include` per line, no timestamps, no absolute paths — so it is byte-reproducible on any machine
|
|
166
|
+
and two concurrent moves into different groups merge in git without intervention.
|
|
167
|
+
`loam subsystem sync` is the one regenerator (it also *removes* the file when the tree has no
|
|
168
|
+
subsystems), every `loam subsystem` write verb regenerates it inside the same transaction, and
|
|
169
|
+
`validate --all` grades staleness by **byte comparison** — exactly one error,
|
|
170
|
+
`subsystem.views-stale`, on exactly that file; nothing in loam ever parses it: a views-only document
|
|
171
|
+
does not parse standalone (its `include` lines reference elements the landscape defines), and it
|
|
172
|
+
holds **static** views, whose contents loam never reads in any document. Only the LikeC4 renderer,
|
|
173
|
+
which merges the whole `architecture/` project, resolves its includes.
|
|
174
|
+
|
|
175
|
+
A move is **one journaled transaction over N directory renames plus the regenerated views file**
|
|
176
|
+
(`loam subsystem move <name>... --into <sub|.>`; `rename` is a one-rename move; whole subtrees move
|
|
177
|
+
as one rename). It refuses only when git reports uncommitted changes under a directory being moved
|
|
178
|
+
(`move-uncommitted` — where git will not say, it proceeds), and after landing it stages the renames
|
|
179
|
+
and the views file in git without committing. Archive snapshots survive any of this: a version-3
|
|
180
|
+
snapshot re-keys `services/` entries by `(service, artifact)` and restores wherever the service
|
|
181
|
+
lives *today*.
|
|
182
|
+
|
|
183
|
+
## Canonical joins
|
|
184
|
+
|
|
185
|
+
| Meaning | Producer | Consumer | Join key |
|
|
186
|
+
|---|---|---|---|
|
|
187
|
+
| Change identity | `features/<FEAT>/`, intent frontmatter | tagged delta elements/edges, generated Gherkin | feature id / `#<FEAT>` |
|
|
188
|
+
| Service identity | `services/<svc>/`, `loam.json.service` | LikeC4 element metadata and feature deltas | service id / `metadata { service }` |
|
|
189
|
+
| Synchronous operation | LikeC4 edge `metadata { op }` | requirement `Operations:` and provider OpenAPI | exact `operationId` |
|
|
190
|
+
| Asynchronous message | LikeC4 edge `publishes` / `consumes` | requirement `Publishes:` / `Consumes:` and AsyncAPI 3 operation/message | exact message id |
|
|
191
|
+
| Architecture coverage | arch requirement `Covers:` | C4 element/edge or `health.yaml` signal | element/edge id, `alert:<id>`, `sli:<id>` |
|
|
192
|
+
| Authorization | requirement `Requires:` | `architecture/permissions.yaml` | `<subject>/<permission>` |
|
|
193
|
+
| Capability | requirement `Capability:` (list; also `Capabilities:`) | `architecture/capabilities.yaml` OR `capabilities/<cap>/spec.md` | declared capability id, `/` allowed for nesting |
|
|
194
|
+
| Capability requirement | requirement `Realizes:` (list) | a requirement in `capabilities/<cap>/spec.md` | `<capability-id>#<Requirement-ID>`, split at the LAST `#`, with an optional `@<digest>` pin |
|
|
195
|
+
| Capability requirement (flow) | a `dynamic view` tag `#req-<slug>` | a requirement of the capability the view's `#cap-` tag names | the `Requirement-ID` with every character outside `[A-Za-z0-9_-]` flattened to `-` |
|
|
196
|
+
|
|
197
|
+
These keys are independent: an operation id is not a permission and a service id is not inferred
|
|
198
|
+
from a feature directory. A file can therefore be structurally valid while a join is broken; the
|
|
199
|
+
broken join is the finding loam reports.
|
|
200
|
+
|
|
201
|
+
The last two exist only inside an `archive`/`unarchive` commit window and are removed after it. Both
|
|
202
|
+
belong in the docs repo's `.gitignore`. Finding one at rest means a write did not finish:
|
|
203
|
+
`loam doctor` grades it, and the next `archive`/`unarchive` repairs or refuses.
|
|
60
204
|
|
|
61
205
|
## `loam.json` — the wiring, one file per repository
|
|
62
206
|
|
|
63
|
-
The docs repo is one repository; each
|
|
207
|
+
The docs repo is one repository; each governed implementation boundary is represented by a code
|
|
208
|
+
repository binding. Every one of them carries a **committed** `loam.json` at its root, and that file
|
|
209
|
+
is the only thing joining them: there is no environment variable, no global config, no registry. A
|
|
210
|
+
command that finds no `loam.json` at or above its working directory refuses with `no-config` rather
|
|
211
|
+
than guessing which system it is in. (Discovery walks upward from the cwd to the repository root,
|
|
212
|
+
so running loam from a subdirectory works; `loam init` writes the file where it is standing.)
|
|
64
213
|
|
|
65
214
|
```json
|
|
66
215
|
{ "docsDir": "../docs-repo", "service": "payment-service", "gherkinDir": "features" }
|
|
67
216
|
```
|
|
68
217
|
|
|
69
|
-
- **`docsDir`** (required) — where the docs repo is. It is stored **exactly as it was passed** and
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
218
|
+
- **`docsDir`** (required) — where the docs repo is. It is stored **exactly as it was passed** and
|
|
219
|
+
resolved against the directory holding the `loam.json`, so `../docs-repo` keeps resolving on every
|
|
220
|
+
machine that checks the two repos out side by side. That is the whole reason the file is
|
|
221
|
+
committed, and why an absolute path is a warning (`doctor.docs-absolute`): it resolves only on the
|
|
222
|
+
machine that ran `loam init`. The docs repo's own file says `"docsDir": "."` — see the bootstrap
|
|
223
|
+
step under "Operating at fleet scale". A `docsDir` pointing at nothing refuses with
|
|
224
|
+
`docs-missing`; one pointing at a directory with no `services/` refuses with `services-missing`
|
|
225
|
+
(most often the service repo itself, after a typo), because an unreadable fleet and an empty fleet
|
|
226
|
+
are different facts and only one of them is fine.
|
|
227
|
+
- **`service`** (required in a governed code repo, absent in the docs repo) — the canonical id of
|
|
228
|
+
the implementation boundary this repository speaks for: the `services/<id>/` directory it is
|
|
229
|
+
allowed to write evidence about. The field name is a frozen compatibility term, not a topology
|
|
230
|
+
claim: `commerce-app` may be a modular monolith with dozens of nested C4 elements and still have
|
|
231
|
+
exactly one service id.
|
|
232
|
+
`loam vouch`, `loam gherkin`, and `loam verify --service` **when it writes with `--record` or
|
|
233
|
+
`--results`** bind to it and refuse (`repository-unavailable`) without it — a stamp, generated
|
|
234
|
+
suite or attestation has to be tied to the code it is about. The read-only
|
|
235
|
+
`loam verify <FEAT> --service <id>` checklist needs no binding and works from the docs repo. The
|
|
236
|
+
id must satisfy the same grammar as `--service` (`[A-Za-z0-9][A-Za-z0-9._-]*`); anything else is
|
|
237
|
+
`config-invalid`.
|
|
238
|
+
- **`gherkinDir`** (optional, default `features`) — the directory `loam gherkin` writes its own
|
|
239
|
+
`loam/` subtree into.
|
|
240
|
+
- **`contracts`** (optional) — where this repository's **build** writes its OpenAPI document,
|
|
241
|
+
e.g. `{ "contracts": { "openapi": "build/openapi.yaml" } }`. `loam validate --service`, run in
|
|
242
|
+
this repo, digests that document and compares it with the committed
|
|
243
|
+
`services/<id>/openapi.yaml`: `openapi.generated-stale` (warn) when they differ,
|
|
244
|
+
`contracts.source-missing` (error) when nothing is there, `contracts.source-invalid` (error) when
|
|
245
|
+
the path escapes the repository or the file does not parse.
|
|
246
|
+
|
|
247
|
+
**loam reads it and never writes it.** The copy into the docs repo stays a human `cp` reviewed in
|
|
248
|
+
a pull request — that is what keeps the committed contract a document somebody agreed to rather
|
|
249
|
+
than a cache of whatever the build last produced. It also stays on the right side of the
|
|
250
|
+
no-extractor line: a standard document the team's own build emitted, at a path a human named,
|
|
251
|
+
parsed and digested, with no meaning derived and no line of service code read. Exactly the
|
|
252
|
+
category `verify --results` already occupies with a cucumber report.
|
|
253
|
+
|
|
254
|
+
The digest is taken over **canonical JSON**, never raw bytes, so two generator versions that order
|
|
255
|
+
keys differently — or a dumper that re-wraps a description — are silent. A permanent warning
|
|
256
|
+
nobody can clear is the first thing a fleet turns off. The whole family is silent from the docs
|
|
257
|
+
repo (there is no build output to read) and for any repo with no `contracts` block.
|
|
258
|
+
|
|
259
|
+
The premise this exists to check is the one this document otherwise assumes without saying so:
|
|
260
|
+
that `services/<id>/openapi.yaml` is the contract the service actually serves. Most fleets
|
|
261
|
+
generate that file and copy it across by hand, so the assumption decays quietly — the spine checks
|
|
262
|
+
go on grading last quarter's endpoints, and every one of them stays green.
|
|
263
|
+
- **`agentTools` / `agentProfile` / `agentFiles`** (optional, written by `loam init`) — the selected
|
|
264
|
+
tool adapters, the `full|service|docs` lifecycle-workflow subset (the support-only `loam-report`
|
|
265
|
+
is present in all three), and a repo-relative path → lowercase
|
|
266
|
+
sha256 manifest for generated command/skill pointers. A digest grants narrowly scoped refresh
|
|
267
|
+
authority: init may update that file only while its current bytes still match. A human edit breaks
|
|
268
|
+
the match and is preserved. These fields never affect validation or lifecycle semantics.
|
|
269
|
+
|
|
270
|
+
`loam init --docs <dir>` **joins** an existing docs repo (one with `services/` and `AGENTS.md`);
|
|
271
|
+
creating a new one requires `--create`, so a mistyped path cannot quietly scaffold a second source
|
|
272
|
+
of truth beside the real one. `--docs` wins only when it is actually passed: a re-run in a repo
|
|
273
|
+
whose committed `loam.json` already names a `docsDir` keeps that pointer, `--create` included, and
|
|
274
|
+
spreads the rest of the config forward. Otherwise following `doctor`'s own advice to re-run `init`
|
|
275
|
+
would repoint a wired repo at an empty decoy, over which `validate --all` goes green.
|
|
276
|
+
`loam init --json` says which source won, as `docsDirSource: "flag" | "config" | "default"`.
|
|
277
|
+
`loam doctor` is the read-only preflight over all of the above: it reads the file defensively
|
|
278
|
+
(corrupt JSON included), resolves `docsDir`, probes access, checks `services/` and the fleet map,
|
|
279
|
+
and reports the binding — every finding carrying a concrete `fix`.
|
|
280
|
+
|
|
281
|
+
The committed bindings are also what `loam open` derives an editor workspace from: each service
|
|
282
|
+
repo's `docsDir` is the join, so a `.code-workspace` naming the docs repo and every checkout bound
|
|
283
|
+
to it can be generated — discovered by a shallow scan beside the docs repo and the repo the command
|
|
284
|
+
runs in (`--root` replaces those roots) — rather than maintained by hand.
|
|
74
285
|
|
|
75
286
|
## Conventions
|
|
76
287
|
|
|
77
|
-
**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
288
|
+
**Documents link to each other with standard markdown links**, and the target is a relative path:
|
|
289
|
+
`[0001 — transactional outbox](../../architecture/adrs/0001-transactional-outbox.md)`. Two reasons,
|
|
290
|
+
and the second is the one that decides it. A markdown link renders as a link in pull-request review,
|
|
291
|
+
which is where these documents are actually read, while `[[0001 transactional outbox]]` renders
|
|
292
|
+
there as the literal brackets somebody typed. And its target is a real relative path, so "does this
|
|
293
|
+
link resolve" is a filesystem question with a yes-or-no answer — where resolving a wikilink means
|
|
294
|
+
reimplementing Obsidian's shortest-unique-path search across the whole repo, which is guessing, and
|
|
295
|
+
loam refuses and names rather than guessing. Nothing is lost by the choice: a docs repo is a folder
|
|
296
|
+
of Markdown files, which is exactly what Obsidian reads, so graph view, backlinks and search work
|
|
297
|
+
with no integration at all — and Obsidian's per-vault `Use [[Wikilinks]]` setting turns off, after
|
|
298
|
+
which its own autocomplete and rename-tracking produce markdown links too. The authoring comfort is
|
|
299
|
+
a toggle in an editor, not a decision the fleet has to carry, which is why there is no `loam.json`
|
|
300
|
+
field for it. **`loam validate` resolves these links** — `link.unresolved` (error), one finding per
|
|
301
|
+
document listing every target that names nothing, with its line. A link here is a **join** and not
|
|
302
|
+
decoration: a requirement that names a term wants that term's document linked and that document
|
|
303
|
+
updated; an ADR that supersedes another wants to say which. Those are relationships of the same kind
|
|
304
|
+
as `Operations:` and `Covers:`, and only the ones written in a form that resolves can be resolved —
|
|
305
|
+
which is what makes the convention a contract rather than a preference. Four things are deliberately
|
|
306
|
+
not graded, and each is a question loam has no answer to rather than a leniency: a target outside
|
|
307
|
+
the docs repo (that tree may not be checked out beside these documents), a link to a heading
|
|
308
|
+
(`#section`), and any link inside a fenced block or an inline code span (which is how a document
|
|
309
|
+
shows the convention). CASE is graded, and is the one place loam looks past the filesystem's own
|
|
310
|
+
answer: `existsSync` is case-insensitive on Windows and macOS while GitHub's renderer and every
|
|
311
|
+
Linux CI runner are not, so a link is resolved only when the target exists AND each path segment is
|
|
312
|
+
spelled the way the directory stores it. When only the case is wrong the finding names the stored
|
|
313
|
+
spelling — on every platform alike, so the diagnosis does not depend on which one ran the check. A
|
|
314
|
+
document in the corpus that cannot be decoded is `link.unreadable` (error) and names itself, so an
|
|
315
|
+
ADR saved as UTF-16 cannot blank its service's whole report.
|
|
316
|
+
|
|
317
|
+
**Fleet-level decisions live in `architecture/adrs/`** — "event publishers use a transactional
|
|
318
|
+
outbox", "cross-service calls carry a circuit breaker" — beside the landscape and the two
|
|
319
|
+
vocabularies, because filing a decision about the fleet under whichever service happened to be
|
|
320
|
+
adopted first is how the other fifty never find it. Same `NNNN-<slug>.md` MADR convention as
|
|
321
|
+
`services/<svc>/adrs/`, and exactly the same treatment: `loam list --json` counts the files as
|
|
322
|
+
`fleetAdrs`, the text view prints the count when there is one, and **nothing grades their content**
|
|
323
|
+
— only the links they write, like every other document above. A fleet with no fleet-level ADRs owes
|
|
324
|
+
none, produces no finding and is not behind — which is also why `loam init` does not scaffold the
|
|
325
|
+
directory: git does not keep an empty one, and an empty `adrs/` in a fresh repo would read as an
|
|
326
|
+
obligation nobody has met.
|
|
327
|
+
|
|
328
|
+
**Architectural obligations live in three places on purpose.** An architect has always had one
|
|
329
|
+
checked channel to a team here — a landscape edge carrying `metadata { op 'authorizePayment' }`
|
|
330
|
+
obliges the provider to define that operationId, or `spine.op-undefined` fails the gate — and this
|
|
331
|
+
is the same handoff for the rules that VARY: an outbox on this publisher and not that one, a circuit
|
|
332
|
+
breaker on two edges out of five. A fleet-wide policy document is the wrong shape for those, which
|
|
333
|
+
is why the decision and its scope are separate things. An **ADR** in `architecture/adrs/` says WHAT
|
|
334
|
+
was decided. A **`#obl-<name>` tag** on an element or an edge in `architecture/landscape.likec4`
|
|
335
|
+
says WHERE it applies, so one ADR governs three edges and not the fourth without the document
|
|
336
|
+
forking. **`architecture/obligations.yaml`** declares the names —
|
|
337
|
+
`obligations: {<id>: {description, adr}}`, with `adr` a path relative to the docs repo root — so a
|
|
338
|
+
mistyped tag is an error rather than a word nobody notices. And the owning team's **`Covers:`** line
|
|
339
|
+
says it is met, with a scenario proving it.
|
|
340
|
+
|
|
341
|
+
The file is the axis's opt-in and a fleet without one hears nothing. With one, four grades:
|
|
342
|
+
`obligation.unknown` (error) for a tag no declaration answers, `obligation.unapplied` (warn) for a
|
|
343
|
+
declaration no tag applies — the same asymmetry `permissions.unknown`/`permissions.unenforced`
|
|
344
|
+
carry, because a typo reads like a rule while an unadopted word is an honest state —
|
|
345
|
+
`obligation.uncovered` (warn) for a tagged object no living requirement covers, and
|
|
346
|
+
`obligation.adr-missing` (error) for an `adr:` naming no file. `obligation.invalid` suspends the
|
|
347
|
+
family, as the other two vocabularies' invalid states do. An obligation id **is** its tag suffix, so
|
|
348
|
+
it may hold letters, digits, `_` and `-` only: the capability axis flattens `identity/tokens` into
|
|
349
|
+
`#cap-identity-tokens` and pays for it with a collision arm at every join, while an obligation id
|
|
350
|
+
has no life outside its own tag and is better refused at the source.
|
|
351
|
+
|
|
352
|
+
**`obligation.uncovered` is `c4.uncovered`'s question asked of the map the fleet actually runs on.**
|
|
353
|
+
`c4.uncovered` grades a NEW tagged element or edge in a feature's `delta.likec4` and has never been
|
|
354
|
+
able to say anything about the living landscape; this asks the same thing against the union of every
|
|
355
|
+
service's living `Covers:` lines — both requirement documents, because `Covers:` is one grammar and
|
|
356
|
+
a fleet that wrote its outbox requirement in `spec.md` has said the thing loam is asking about. One
|
|
357
|
+
finding per tagged object, `subject` = the service that owns it, so a fleet-wide report reads as a
|
|
358
|
+
worklist per team. Only a tag that RESOLVES is asked about coverage: an undeclared one has earned
|
|
359
|
+
its error already, and "no requirement covers `#obl-typo`" would be the same breach restated as a
|
|
360
|
+
request to write a requirement for a rule that does not exist.
|
|
361
|
+
|
|
362
|
+
**The fleet's domain vocabulary lives in `glossary/<term>.md`** — one FILE per term, nesting allowed
|
|
363
|
+
(`glossary/payments/authorization.md` is the term `payments/authorization`), and no `glossary.yaml`
|
|
364
|
+
anywhere. A definition is prose, and the rule this settles is general: an entry with prose gets a
|
|
365
|
+
file, an entry without prose stays a line in YAML. So the DIRECTORY is the list, exactly as it is
|
|
366
|
+
for `capabilities/`, and a second list is the drift `loam init`'s removed service manifest was
|
|
367
|
+
removed for. The directory's existence is the axis's opt-in — a fleet without one produces no
|
|
368
|
+
finding and pays one `existsSync`. `README.md` is not a term: a glossary big enough to want an index
|
|
369
|
+
page should not have that page reported as a word nobody uses.
|
|
370
|
+
|
|
371
|
+
**A term is checkable because a link is a join.** The document that uses the word cites the
|
|
372
|
+
definition with an ordinary relative link —
|
|
373
|
+
`An [Order](../../glossary/order.md) is what a customer confirmed.` — which `link.unresolved`
|
|
374
|
+
already resolves, so "which documents use this term" is a query rather than a grep, and
|
|
375
|
+
`glossary.unlinked` (warn) names a term no document outside `glossary/` cites: a word the fleet
|
|
376
|
+
never adopted, or a definition left behind by a rename. The alternative that was rejected is worth
|
|
377
|
+
naming, because it is the obvious one: matching a term's WORDS against prose would convict a
|
|
378
|
+
payments capability for the word "payments", and loam does not run that class of check anywhere. **A
|
|
379
|
+
citation from another term does not count as adoption** — a glossary is a network of definitions, so
|
|
380
|
+
`order-line` linking to `order` says the glossary is consistent, not that the fleet speaks it; the
|
|
381
|
+
citing terms are listed in the finding's `details` rather than hidden. Terms cited only from
|
|
382
|
+
`features/archive/` do not count either: a shipped feature's documents describe the tree as it was,
|
|
383
|
+
and a word retired two releases ago must not cite itself alive forever. `loam list glossary --json`
|
|
384
|
+
carries every term with its `linkedBy` documents, and a sibling `links` object whose `unreadable`
|
|
385
|
+
list names what the citation walk could not decode — an empty `linkedBy` must never be confusable
|
|
386
|
+
with "nobody looked".
|
|
387
|
+
|
|
388
|
+
**A feature can bring a new word with it**: `features/<FEAT>/glossary/<term>.md` is copied into
|
|
389
|
+
`glossary/` by `loam archive` and removed again by `loam unarchive`, so the vocabulary a change
|
|
390
|
+
introduces ships and unships with it and is reviewed beside the requirements that use it. The route
|
|
391
|
+
is **create-only**, and that is a decision rather than an unfinished half: a capability delta is a
|
|
392
|
+
delta because it merges into a living document part by part, while a term document has one part, so
|
|
393
|
+
the merge is a whole-file copy with nothing to merge partially and no pin to collide on. A term the
|
|
394
|
+
living glossary already defines is `glossary.term-exists` (error), and **`--approve` does not
|
|
395
|
+
override it** — approving it would be approving a deletion nobody described, which is the same class
|
|
396
|
+
of refusal `c4.service-binding-invalid` is. Change a definition by editing `glossary/<term>.md`
|
|
397
|
+
directly in the same pull request, where git produces an ordinary conflict.
|
|
398
|
+
|
|
399
|
+
**A citation inside a requirement is re-expressed by the merge, and it has to be.** A feature's
|
|
400
|
+
`specs/<svc>/spec.md` sits four directories below the docs root and the living
|
|
401
|
+
`services/<svc>/spec.md` it merges into sits two, so a relative link copied verbatim lands pointing
|
|
402
|
+
above the repository. `loam archive` therefore rewrites every relative link in the text it moves —
|
|
403
|
+
the target is unchanged, only the route to it — which is what lets an author write the link that
|
|
404
|
+
resolves where they wrote it, in the pull request that reviews it, and keeps it resolving after it
|
|
405
|
+
ships. This applies to the service and capability spec merges alike, and predates the glossary: a
|
|
406
|
+
delta requirement citing an ADR always had it. A link inside a fenced block or a code span, an
|
|
407
|
+
absolute URL, a site-root path and a bare `#section` are left exactly as written.
|
|
408
|
+
|
|
409
|
+
**Frontmatter**: `status`, `owner`, `service` or `feature`, `last_verified`, `vouched_by`, `sources`
|
|
410
|
+
(literal files and directories), `sources_digest`, `content_digest`, `sources_files`, `vouch_scope`.
|
|
411
|
+
`loam validate` reads it in exactly three files — a service's living `spec.md`, its living
|
|
412
|
+
`arch.spec.md` when present (same conventions, same checks, only the label differs), and a feature's
|
|
413
|
+
`intent.md`: the documents whose identity and status everything else joins on. `adrs/` and
|
|
414
|
+
`runbook.md` are presence-tracked (`loam list` says whether they exist), as is the fleet's own
|
|
415
|
+
`architecture/adrs/` beside them; their frontmatter is read by nothing today, so a field written
|
|
416
|
+
there is a note to a future reader, not a claim any check will catch. Their markdown links are the
|
|
417
|
+
one thing in them loam does read — `link.unresolved` above. `health.yaml`'s frontmatter is equally
|
|
418
|
+
unread, but the file itself is not inert: `loam validate` reads its top-level `slis:`/`alerts:` ids
|
|
419
|
+
for `Covers:` resolution and the `health.uncovered` warning, and its top-level `dependencies:` ids,
|
|
420
|
+
which weigh in as model-shape evidence (`c4.no-relationships`) and are reconciled against the
|
|
421
|
+
service's own model (`health.dependency-unmodelled`) — see the architecture spec axis below. For the
|
|
422
|
+
two checked files:
|
|
423
|
+
|
|
424
|
+
- `status` — services: `draft` -> `verified`; features: `proposed` -> `in_progress` -> `built` ->
|
|
425
|
+
`done`. An undocumented value is an **error**: a typo (`verifed`) would otherwise read as
|
|
426
|
+
unverified forever.
|
|
427
|
+
- a frontmatter block that does not parse as YAML is an **error** of its own
|
|
428
|
+
(`frontmatter.malformed`), and the only finding that file's header produces: the fields are
|
|
429
|
+
unreadable, not absent, and the cascade the silent parse used to trigger — "owner missing",
|
|
430
|
+
"status missing", "names no sources" — sent authors adding lines to a block YAML refuses. The
|
|
431
|
+
field checks and the sources chain resume once the header parses.
|
|
432
|
+
- `service` / `feature` — must match the directory the file lives under. A mismatch is an **error**;
|
|
433
|
+
absence is a warning.
|
|
434
|
+
- `sources` — paths in the *service's own repo* the artifact was written from: literal files and
|
|
435
|
+
directories only, a directory meaning everything beneath it. Resolved when `loam` runs inside that
|
|
436
|
+
repo (`service` in `loam.json`); a path that no longer exists is an **error**, and so is a
|
|
437
|
+
glob-looking entry (`*`, `?` or `[`) — patterns are not supported, and are refused loudly rather
|
|
438
|
+
than matched, because a pattern dialect nobody can be sure of digests a different file set than
|
|
439
|
+
its author intended, corrupting the trust signal silently. This is the only mechanical tie between
|
|
440
|
+
the docs and the code: everything else loam checks is internal consistency, which a fluent fiction
|
|
441
|
+
satisfies too.
|
|
442
|
+
- `sources_digest` / `last_verified` / `vouched_by` — written by `loam vouch`, never by hand.
|
|
443
|
+
`vouched_by` is git's identity for the repo the vouch ran in (`Name <email>`): a name, not a
|
|
444
|
+
signature, and it proves nothing cryptographically — what it buys is that a reviewer can ask a
|
|
445
|
+
*specific person* what they read, where `status: verified` alone recorded only that some process
|
|
446
|
+
wrote the word. The digest is a content hash of the files `sources` names, taken when a human last
|
|
447
|
+
read them; `loam validate`, run in that repo, recomputes it and reports `sources.current`,
|
|
448
|
+
`sources.stale` (the code moved since anyone looked), or `sources.unvouched` (`sources` with no
|
|
449
|
+
digest — nobody has ever stamped it). All warnings: staleness is a signal, and only a person can
|
|
450
|
+
say whether the doc is still right.
|
|
451
|
+
- `content_digest` — written by `loam vouch`, never by hand, like its siblings: a hash of the
|
|
452
|
+
document's OWN body, so the stamp covers the words as well as the code. Editing a spec after it
|
|
453
|
+
was vouched used to leave `status: verified` standing over prose nobody read — the exact forgery
|
|
454
|
+
an agent-written corpus invites. `loam validate` recomputes it wherever the doc is readable; this
|
|
455
|
+
check needs **no service repo**, so unlike the `sources.*` chain it runs for `--service` and
|
|
456
|
+
`--all` alike, from the docs repo included. A `verified` spec whose current body hash mismatches
|
|
457
|
+
gets `content.stale` (**warning**, the `sources.stale` grading: the doc changed since it was
|
|
458
|
+
vouched, and only a person can say whether verified still holds). A verified doc with no
|
|
459
|
+
`content_digest` predates the stamp and stays quiet — the fix is re-vouching.
|
|
460
|
+
- `vouch_scope` — written by `loam vouch --sample <n>`, never by hand, and present only when the
|
|
461
|
+
person who vouched read a recorded SAMPLE of the document rather than all of it. One flat string,
|
|
462
|
+
`sampled <k>/<n> seed=<16 hex>`: k sections read of n, and the seed that chose them. `status`
|
|
463
|
+
stays `verified` and the maturity rung stays `vouched` — the scope *qualifies* that claim, it is
|
|
464
|
+
not a fourth status — while `loam validate` reports `sources.sampled-vouch` (**warning**, the
|
|
465
|
+
`content.stale` grading: doc-side, so it fires from the docs repo too), `loam list` shows
|
|
466
|
+
`vouched (sampled)` and adds `vouchScope: "sampled"` to the row, `loam show` renders
|
|
467
|
+
`verified (sampled)`, and `validate --all`'s scorecard counts it under
|
|
468
|
+
`provenance.sampledVouched`. A value that does not decode is graded **sampled anyway** — the test
|
|
469
|
+
is whether the KEY is there, not whether its value is legible, so a scope rewritten as a YAML
|
|
470
|
+
mapping or sequence still reads as a partial vouch everywhere; mangling one field must never
|
|
471
|
+
promote a partial read to a full one. Either spec axis carrying one makes the SERVICE sampled on
|
|
472
|
+
`loam list`, `loam show` and `loam status`, since the sample is per file. A later FULL
|
|
473
|
+
`loam vouch` deletes the field, which is the only thing that clears the warning; deleting it by
|
|
474
|
+
hand forges a full vouch out of a partial one, and the generated `AGENTS.md` says so.
|
|
85
475
|
- Missing fields are warnings, and `loam list` reports the fleet's draft/verified/unmarked split.
|
|
86
476
|
|
|
87
|
-
**Vouching** (`loam vouch --service <id>`, run in the service's own repo) is the human promotion
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
477
|
+
**Vouching** (`loam vouch --service <id>`, run in the service's own repo) is the human promotion
|
|
478
|
+
`draft` -> `verified`: it stamps `status`, `last_verified`, `vouched_by`, `sources_digest`,
|
|
479
|
+
`content_digest` and `sources_files` together — into the living `spec.md`, and into `arch.spec.md`
|
|
480
|
+
beside it when the service has one — rewriting only the frontmatter. It refuses what it cannot
|
|
481
|
+
verify — a frontmatter block that will not parse as YAML (its fields are unreadable, and a stamp
|
|
482
|
+
would have to rewrite the header wholesale, losing the author's lines), no `sources`, a glob-pattern
|
|
483
|
+
entry (not supported), a source path that is gone, sources expanding to no files at all, or a repo
|
|
484
|
+
that is not that service's — checked per file, the refusal naming which, and all-or-nothing per
|
|
485
|
+
service: one unverifiable file refuses the whole run, so a `verified` status always has a digest
|
|
486
|
+
behind it and never means half the pair was stamped. The write keeps the same promise: the pair is
|
|
487
|
+
committed through staged temp-file swaps (archive's machinery), so a failure between the two files
|
|
488
|
+
rolls the first back (`merge-failed` — nothing stamped, re-run) instead of leaving half a stamp;
|
|
489
|
+
only a rollback that itself fails leaves the repo to a human (`rollback-incomplete`, the message
|
|
490
|
+
naming the files). It also refuses to be run BY the thing it is meant to check: without `--yes` it
|
|
491
|
+
requires a terminal and asks (`vouch-unattended` on a pipe or in `--json`, `vouch-declined` on
|
|
492
|
+
anything but an explicit yes), and it refuses `vouch-unattributable` when git can name nobody at
|
|
493
|
+
all. The command and skill files `loam init` writes pre-approve loam's other verbs one by one and
|
|
494
|
+
deliberately not this one, so an agent that has finished a draft hands back and says a vouch is
|
|
495
|
+
owed. And because the stamp is computed from bytes read a moment earlier, the swap re-checks them: a
|
|
496
|
+
document that changed under the vouch — a concurrent vouch, a hand edit — refuses with `vouch-raced`
|
|
497
|
+
and writes nothing, rather than stamping a digest of text that is no longer there. `--sample <n>`
|
|
498
|
+
narrows what the person is asked to read (see **Sampling** below) and nothing else: the same
|
|
499
|
+
refusals fire, before the reading list is printed rather than after it, the same attendance gates
|
|
500
|
+
hold, and a document that moved while somebody was reading the sample refuses `vouch-raced` too —
|
|
501
|
+
the sections they read are not the sections the stamp would cover.
|
|
502
|
+
|
|
503
|
+
The digest recipe (stable, and part of the contract): expand `sources` to repo-relative file paths —
|
|
504
|
+
a file is itself, a directory means everything beneath it, dot-entries are skipped while walking
|
|
505
|
+
(though a path naming one outright is honoured) — sort them, hash each file's bytes with sha256,
|
|
506
|
+
feed `<path>\0<sha256-hex>\n` per file into an outer sha256, and keep the first 16 hex characters.
|
|
507
|
+
|
|
508
|
+
Two exclusions are part of that expansion, because a digest is a *staleness signal* and both of
|
|
509
|
+
these turned it into noise:
|
|
510
|
+
|
|
511
|
+
- **What git ignores is not hashed.** `node_modules` is skipped by name unconditionally, and the
|
|
512
|
+
rest is asked of git itself — one `git check-ignore` over the expanded list, run in the service
|
|
513
|
+
repo. A build output tree under a listed source used to flip `sources.stale` on every compile,
|
|
514
|
+
which is the fastest way to teach a team to ignore the signal. The fallback direction is
|
|
515
|
+
deliberate: if git cannot answer (not a repository, git absent, the call times out) **everything
|
|
516
|
+
is hashed**, because a missed exclusion costs noise while an invented one costs a file the stamp
|
|
517
|
+
silently stops watching. A `sources` entry that expands to nothing after this filter is
|
|
518
|
+
`sources.empty` (warn) — a digest over no files reads as current forever — and `loam vouch`
|
|
519
|
+
refuses to stamp it with the same sentence.
|
|
520
|
+
- **Symlinks are followed only inside the repository.** A link to a file inside the repo is hashed
|
|
521
|
+
under the link's own spelling; a link to a directory inside the repo is walked, with a stack of
|
|
522
|
+
realpaths as the cycle guard (a global visited-set would make the file set depend on `readdir`
|
|
523
|
+
order when one directory is reachable by two spellings — two machines with identical bytes, two
|
|
524
|
+
different digests). A link whose target is outside the repository, dangling, or neither a file nor
|
|
525
|
+
a directory is **not** hashed and is reported instead: `sources.skipped` (warn), naming each path
|
|
526
|
+
and why. It is not closable by editing the document — either the link should not be under a listed
|
|
527
|
+
source, or `sources` should name the real path.
|
|
528
|
+
|
|
529
|
+
Alongside the digest, `loam vouch` stamps **`sources_files`**: the per-file index (`<sha256> <path>`
|
|
530
|
+
lines, sha256sum layout, in a block scalar) the digest was computed over. It is what lets a later
|
|
531
|
+
`sources.stale` say *which* paths were added, changed or removed instead of only that something
|
|
532
|
+
moved. Above `SOURCE_INDEX_LIMIT` (100) files the field records the count alone — a readability
|
|
533
|
+
budget — and an absent or unparseable field reads as "the stamp said nothing about individual
|
|
534
|
+
files", explicitly not "every file was deleted". Like its siblings it is written by `loam vouch` and
|
|
535
|
+
never by hand. Glob patterns are not part of the recipe: an entry containing `*`, `?` or `[` never
|
|
536
|
+
expands — `loam vouch` refuses it and `loam validate` errors on it — because a pattern dialect
|
|
537
|
+
nobody can be sure of silently digests a different file set than intended, corrupting the trust
|
|
538
|
+
signal in both directions. (The literal-path expansion is unchanged from when patterns existed, so
|
|
539
|
+
documents vouched with literal paths keep their digests.) Content, not mtime: git does not preserve
|
|
540
|
+
modification times, so an mtime check would call every file stale after a fresh clone.
|
|
541
|
+
|
|
542
|
+
The body digest recipe (`content_digest`, same discipline): sha256 of the artifact's body bytes
|
|
543
|
+
AFTER the frontmatter block — the closing `---` line and its newline belong to the header, every
|
|
544
|
+
byte below them is body — first 16 hex characters kept. Byte-exact, no normalization, and body-only
|
|
545
|
+
on purpose: vouch itself rewrites the frontmatter as it stamps, so a header-only edit (a later
|
|
546
|
+
vouch, a corrected `owner`) must not read as the document moving.
|
|
547
|
+
|
|
548
|
+
**Sampling** (`loam vouch --sample <n>`) is the same act over less of the document, recorded
|
|
549
|
+
honestly. Its recipe is part of the contract too, because the value is written into documents and
|
|
550
|
+
the whole point is that anybody can recompute what a person was shown:
|
|
551
|
+
|
|
552
|
+
1. the sampling UNITS of a file are every H2 and H3 heading of its body outside a code fence, each
|
|
553
|
+
standing for the text under it up to the next heading of either level, in document order. H3
|
|
554
|
+
counts because `## Requirements` holds every `### Requirement:` in a living spec, and a sample
|
|
555
|
+
whose unit is most of the file is not a sample. The text above the first heading is never a unit:
|
|
556
|
+
the reading list names it separately ("read the preamble") outside the k/n count;
|
|
557
|
+
2. the SEED is `sha256(<service> \0 <content_digest> \0 <sources_digest>)`, first 16 hex characters
|
|
558
|
+
— per FILE, since both inputs are per-file stamps, and derived from the document's own content so
|
|
559
|
+
that the pick cannot be re-rolled in place: a random seed would let an agent run the vouch (or
|
|
560
|
+
`--pack --sample`) until the section it wrote carelessly fell outside the sample, leaving no
|
|
561
|
+
trace at all, where content derivation makes every re-roll an EDIT the docs repo's history
|
|
562
|
+
records. It does **not** make the sample unpredictable, and the difference is worth being plain
|
|
563
|
+
about: this recipe is published, so anybody holding the document can compute the pick before
|
|
564
|
+
anybody vouches — that is the audit property, and the two are in direct tension. Steering costs
|
|
565
|
+
one cosmetic edit per attempt in a version-controlled file, immediately before asking a person to
|
|
566
|
+
vouch; auditability was chosen over unpredictability because hiding the recipe needs a secret
|
|
567
|
+
loam has nowhere to keep, and a sample nobody can check is not evidence of anything;
|
|
568
|
+
3. the PICK ranks each unit by `sha256(<seed> \0 <0-based index> \0 <heading line, trimmed>)`, sorts
|
|
569
|
+
by that hex ascending (index breaking a tie), takes the first `<n>`, and lists them in document
|
|
570
|
+
order. No PRNG, no dependency, one section at a time recomputable with a shell;
|
|
571
|
+
4. a file with `<n>` or fewer units is read in FULL and carries no `vouch_scope`; every `sources`
|
|
572
|
+
entry is read whole in either case, because nothing in loam maps a source file to a doc section
|
|
573
|
+
and inventing that mapping would be exactly the meaning-derivation loam refuses. The sample is of
|
|
574
|
+
sections; the digest still covers all of the code.
|
|
575
|
+
|
|
576
|
+
The pick is UNIFORM by design: it is deliberately not weighted by fan-in (`loam list
|
|
577
|
+
--review-order`), which counts dependants per SERVICE with no section-level join, would zero-weight
|
|
578
|
+
the narrative sections most likely to be quietly wrong, and would take its weights from documents an
|
|
579
|
+
agent can edit — reopening the steering that a content-derived seed closes.
|
|
580
|
+
`loam vouch --pack --sample <n>` prints the same pick before the vouch; a pack read after a sampled
|
|
581
|
+
vouch recomputes what that vouch read from the stamped seed, so its "already covered" list can never
|
|
582
|
+
count sections nobody opened.
|
|
583
|
+
|
|
584
|
+
**Tags (LikeC4)**: element kinds and tags are declared in a `specification` block; a delta's
|
|
585
|
+
new/changed elements carry the feature id as a tag (`#FEAT-101`) so `loam` can project the delta by
|
|
586
|
+
tag and validate it. The tag is loam's only way of seeing the delta, so a `delta.likec4` that
|
|
587
|
+
declares elements or relationships with none carrying the feature's tag is an **error**
|
|
588
|
+
(`delta.nothing-tagged`): everything in it is invisible to the checks, and the archive would merge
|
|
589
|
+
nothing.
|
|
590
|
+
|
|
591
|
+
**Service binding (element ↔ `services/<svc>/`)**: an element says which service it *is* with
|
|
592
|
+
`metadata { service 'payment-service' }`. Without one, its **title** is used — which is what every
|
|
593
|
+
existing repo relies on, and also the trap: rename a box in a diagram and every check that joined it
|
|
594
|
+
to its directory silently stops matching. The binding is what makes a rename safe.
|
|
595
|
+
|
|
596
|
+
There is no manifest of services: the directories under `services/` **are** the list.
|
|
597
|
+
`loam validate --all` cross-checks that list against the landscape in both directions:
|
|
598
|
+
|
|
599
|
+
- a `services/<svc>/` no element resolves to → `landscape.service-unmodelled` (**error**): the fleet
|
|
600
|
+
map is incomplete, and every view derived from it is wrong;
|
|
601
|
+
- an element that looks like a service, with no directory → `landscape.service-undocumented`
|
|
602
|
+
(**warning**): it may be someone else's system. Tag it `#external` to say so, and the warning
|
|
603
|
+
stops. **Depth is not a fact about a service.** The check used to keep only top-level elements,
|
|
604
|
+
which ordinary grouped C4 breaks the moment services sit under an `enterprise`/`group`/`boundary`
|
|
605
|
+
parent: every nested element was discarded, so a perfectly bound service read as unmodelled. The
|
|
606
|
+
tree is walked instead — an element is graded at service level when no *ancestor* already stands
|
|
607
|
+
for a service (its binding first, then a title naming a real `services/<id>/`), and what sits
|
|
608
|
+
inside one of those is that service's container. Two consequences worth stating: an element that
|
|
609
|
+
CONTAINS a service is a grouping, has nothing to bind and nothing to tag, and never raises this
|
|
610
|
+
warning; and a `person`-kind element is exempt as before;
|
|
611
|
+
- an element whose explicit binding names a directory that does not exist →
|
|
612
|
+
`landscape.binding-unknown` (**error**), graded **at any depth**, not only at top level — a typo
|
|
613
|
+
one level down used to bind an edge to a service that does not exist with nothing saying so. A
|
|
614
|
+
binding is a claim about this repo; a title that fails to match is only a guess at one, which is
|
|
615
|
+
why the two are graded differently;
|
|
616
|
+
- two elements resolving to the **same** `services/<svc>/` → `landscape.binding-duplicate`
|
|
617
|
+
(**warning**): every element→service join picks one of them arbitrarily, so the other's edges are
|
|
618
|
+
quietly filed under a service that does not own them.
|
|
619
|
+
|
|
620
|
+
**`architecture/landscape.likec4` is required, not optional.** Its absence is a finding, never a
|
|
621
|
+
skipped check: an **error** as soon as `services/` holds at least one service directory, and a
|
|
622
|
+
**warning** in a docs repo whose `services/` is still empty (`landscape.missing`, naming the file
|
|
623
|
+
and what to write in it — a `specification` block declaring the kinds, a `model` block with one
|
|
624
|
+
bound element per service, one edge per cross-service call carrying `metadata { op }`). The reason
|
|
625
|
+
it cannot be silence: with no map, the whole cross-service layer — the C4↔API spine,
|
|
626
|
+
`landscape.service-unmodelled`, every derived view — computes nothing and *reports* nothing, which
|
|
627
|
+
reads exactly like a clean fleet. `loam init` scaffolds the file (empty, commented, parsing clean)
|
|
628
|
+
so a fresh docs repo starts from a real one rather than from that ambiguity, and `loam adopt` briefs
|
|
629
|
+
the block each service still owes it.
|
|
630
|
+
|
|
631
|
+
Absences inside a service directory are graded too. A directory with no `spec.md` is a **warning**
|
|
632
|
+
(`service.no-spec`) — partial adoption is a supported state, but the checks that file feeds are
|
|
633
|
+
silently vacuous without it, and that has to be said.
|
|
634
|
+
|
|
635
|
+
A missing `openapi.yaml` is graded on what already points into it, under one code,
|
|
636
|
+
`service.no-openapi`, at three grades:
|
|
637
|
+
|
|
638
|
+
- **silence**, on positive evidence that nobody expects an API there: the landscape parses and no
|
|
639
|
+
op-carrying edge targets the service;
|
|
640
|
+
- **warning**, when nothing joins into the file and the landscape cannot prove nobody calls the
|
|
641
|
+
service (absent or unparseable) — "I could not look" and "nobody calls me" are different facts and
|
|
642
|
+
only one of them is fine;
|
|
643
|
+
- **error**, when something already joins into it: a living non-`REMOVED` requirement carrying an
|
|
644
|
+
`Operations:` line, or an op-linked landscape edge whose target resolves here. Those operationIds
|
|
645
|
+
ride in the finding's `details`. A requirement governing an operation that resolves to nothing is
|
|
646
|
+
the same breach `spec-api.op-undefined` grades when the file exists and the op does not; the file
|
|
647
|
+
being absent is not a reason to grade it lower.
|
|
648
|
+
|
|
649
|
+
And `loam validate --service <id>` for a directory that does not exist at all is an **error**
|
|
650
|
+
(`service.unknown`) that suggests close ids — never `loam adopt`, which would steer an agent into
|
|
651
|
+
adopting the typo. Only a legal id earns that grading: a name that is neither an existing directory
|
|
652
|
+
nor a legal service id is refused as `invalid-option` before any path is built from it. A
|
|
653
|
+
`features/<FEAT>/specs/<svc>/` directory addressing a service that neither exists as
|
|
654
|
+
`services/<svc>/` nor is introduced by that feature's own tagged `delta.likec4` is the same class of
|
|
655
|
+
mistake and gets its own **error**, `delta.service-unknown`, naming close ids: archiving would
|
|
656
|
+
otherwise materialise a phantom service directory out of a typo in `--touches`. A delta that did not
|
|
657
|
+
parse suspends the question rather than guessing (`delta.invalid` is that finding).
|
|
658
|
+
|
|
659
|
+
**API linkage (operationId)**: OpenAPI's `operationId` joins architecture and behavior — it is the
|
|
660
|
+
token both C4 and specs reference.
|
|
661
|
+
- a C4 relationship names the operation it calls:
|
|
662
|
+
`... -> ... 'Calls createSplit' { metadata { op 'createSplit' } }`;
|
|
125
663
|
- a requirement declares the operations it governs: an `Operations: createSplit` line;
|
|
126
664
|
- the operation is defined in the provider service's `openapi.yaml`.
|
|
127
665
|
|
|
128
|
-
`loam validate` then checks the **contract** (every edge's `op` exists in the target's OpenAPI —
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
666
|
+
`loam validate` then checks the **contract** (every edge's `op` exists in the target's OpenAPI —
|
|
667
|
+
consumer-driven, gates) and **coverage** (every operation is governed by a requirement). Only the
|
|
668
|
+
eight HTTP methods of a path item are read for operationIds — an id tucked inside a vendor extension
|
|
669
|
+
(`x-legacy:`) does not exist, because a phantom id would mask a contract break. A service whose
|
|
670
|
+
openapi defines operations while not one requirement carries an `Operations:` line gets a single
|
|
671
|
+
**warning** (`api.ops-unlinked`): the API axis is unchecked there until someone links it — the exact
|
|
672
|
+
state an adopted OpenSpec repo starts in. An `openapi.yaml` that exists but does not parse is an
|
|
673
|
+
**error** (`openapi.invalid`) — including one whose bytes are not valid UTF-8, which is graded as
|
|
674
|
+
unreadable rather than handed to the YAML parser with U+FFFD substituted in. The checks that read
|
|
675
|
+
the contract — the `api.*` family and the spine's op resolution — are suspended for that service: an
|
|
676
|
+
unreadable contract used to read as an EMPTY one, grading every inbound edge `spine.op-undefined`
|
|
677
|
+
when the truth was the file. **A missing file suspends them the same way**, and for the same reason:
|
|
678
|
+
a contract that is not there proves nothing about an edge, neither broken nor resolved. So one
|
|
679
|
+
absent contract is exactly one finding (`service.no-openapi`, carrying the stranded operationIds)
|
|
680
|
+
rather than one error per inbound consumer, and no `spine.resolved` is claimed either.
|
|
681
|
+
|
|
682
|
+
Two `operationId`s that are the same in one document is a **warning**
|
|
683
|
+
(`openapi.duplicate-operationid`), naming both `METHOD /path` slots — in a living contract and in a
|
|
684
|
+
feature delta alike, one breach with one name wherever it is found. The document is legal OpenAPI;
|
|
685
|
+
the ambiguity is in everything that joins on the id, each of which picks one slot arbitrarily.
|
|
686
|
+
|
|
687
|
+
**Refusal paths are governed too.** For every exact three-digit `4xx` or `5xx` response declared by
|
|
688
|
+
an operation, at least one scenario of a requirement naming that operation should mention the status
|
|
689
|
+
code in its body; Scenario Outline example rows count. Missing coverage is `api.response-ungoverned`
|
|
690
|
+
(warning). `default`, `4XX` and `5XX` wildcard responses are excluded because no single scenario can
|
|
691
|
+
name the concrete branch they represent. This is a textual reachability check, not proof that the
|
|
692
|
+
implementation returns the response.
|
|
693
|
+
|
|
694
|
+
**Deprecation is visible on the spine.** Long-lived APIs migrate — legacy operations coexist with
|
|
695
|
+
their replacements — and OpenAPI already carries the standard per-operation `deprecated: true` flag,
|
|
696
|
+
so loam reads it (nothing else changes in what the reader touches: still the eight HTTP methods,
|
|
697
|
+
still the operationId) and turns it into lifecycle warnings, letting the spine say "migrating off"
|
|
698
|
+
instead of only alive-or-broken. A landscape edge calling a deprecated operation warns per inbound
|
|
699
|
+
edge (`spine.op-deprecated`: the contract holds, but the consumer should be migrating — two
|
|
700
|
+
op-linked edges from one consumer are two warnings, and a deprecated op nobody calls raises no spine
|
|
701
|
+
warning); a requirement whose `Operations:` list resolves only to deprecated operations warns once
|
|
702
|
+
(`api.requirement-deprecated`: the behaviour it governs is on its way out — migrate it to the
|
|
703
|
+
replacement operation, or retire it); and a feature delta whose NEW tagged edge consumes an op the
|
|
704
|
+
living provider contract deprecates warns in feature scope (`c4-api.op-deprecated`, advisory — it
|
|
705
|
+
never gates `archive`: building new consumption on a dying op deserves an eye, not a block; quiet
|
|
706
|
+
when the feature's own openapi delta restates the op without the flag — that feature IS the
|
|
707
|
+
un-deprecation, and the warning family stops once it archives). All three are warnings, and
|
|
708
|
+
`--strict` is the escalation.
|
|
709
|
+
|
|
710
|
+
**Removal is an explicit feature delta.** To retire an operation after deprecation, put the exact
|
|
711
|
+
living path and HTTP method in the feature's `specs/<svc>/openapi.yaml`, keep its `operationId`, and
|
|
712
|
+
add `x-loam-remove: true` **inside that operation object**, beside its `operationId`. A marker one
|
|
713
|
+
level up, at path level beside the methods, names no operation: it retires nothing, and it is not a
|
|
714
|
+
contract key either. `loam archive` gates it (`openapi.remove-marker-path-level`, error, overridable
|
|
715
|
+
with `--approve` like the other judgement gates) and the marker never reaches the living document
|
|
716
|
+
under any flag — `x-loam-remove` and `x-loam-based-on` are stripped at path level and operation
|
|
717
|
+
level alike, on every merge branch. A path item consisting of nothing but a marker publishes no
|
|
718
|
+
phantom path. The same feature must carry a `REMOVED` requirement whose `Operations:` line names
|
|
719
|
+
that id. Coherence gates an absent path+method, an id mismatch, an unjustified marker, a removed
|
|
720
|
+
requirement with no marker, and a new tagged C4 edge consuming the operation being removed.
|
|
721
|
+
`serviceOperationIds` evaluates the feature as living upserts minus removals, so other same-feature
|
|
722
|
+
checks see the post-archive contract. Archive deletes only the matching method, reports removals in
|
|
723
|
+
text and `openapiRemovals` JSON, never copies the marker into living OpenAPI, and deliberately does
|
|
724
|
+
not garbage-collect components. A removal marker in a living `openapi.yaml` is
|
|
725
|
+
`openapi.remove-marker-living` (error). The usual staged-write rollback and `unarchive` snapshot
|
|
726
|
+
restore apply byte-for-byte.
|
|
135
727
|
|
|
136
728
|
### The async contract axis (`asyncapi.yaml`)
|
|
137
729
|
|
|
138
|
-
A `paymentService -> kafka 'Publishes PaymentAuthorized'` edge used to read as fully documented
|
|
730
|
+
A `paymentService -> kafka 'Publishes PaymentAuthorized'` edge used to read as fully documented
|
|
731
|
+
while naming no contract anybody could check. The event axis is the API axis's mirror, in **AsyncAPI
|
|
732
|
+
3.0** — only 3.0, because its operations are named top-level objects carrying
|
|
733
|
+
`action: send|receive`, the exact analog of an `operationId`, where 2.x spells the same thing as
|
|
734
|
+
`publish`/`subscribe` under a channel and leaves whose perspective they take ambiguous. A 2.x
|
|
735
|
+
document declares no `operations` and reads as a contract with no messages rather than as a
|
|
736
|
+
mis-parsed one. Like OpenAPI it is read with a shallow structural walk over the `yaml` parse, never
|
|
737
|
+
a real AsyncAPI parser: the slice loam joins on is small enough that the parser's model would be
|
|
738
|
+
dead weight, and adding it would be a fourth runtime dependency.
|
|
739
|
+
|
|
740
|
+
**The join token is a message name**, spelled three times and required to be identical: a landscape
|
|
741
|
+
edge's `metadata { publishes '...' }` / `metadata { consumes '...' }`, a requirement's `Publishes:`
|
|
742
|
+
/ `Consumes:` line (both requirement files — an outbox requirement in `arch.spec.md` is the
|
|
743
|
+
canonical home of a `Publishes:` line, and the same keep-last quirk applies as to `Operations:`),
|
|
744
|
+
and the message's `name` in `asyncapi.yaml`, or its declaration key when it has none.
|
|
745
|
+
|
|
746
|
+
**The payload is opaque, by decision.** loam reads message names and the `action` of the operation
|
|
747
|
+
carrying them, and nothing inside `payload`. That is the whole property that keeps the payload
|
|
748
|
+
format a documentation choice: a fleet writes JSON Schema today, and one that later adopts Avro
|
|
749
|
+
changes a `schemaFormat` line and nothing in loam. Keep payloads in `components/schemas` in the same
|
|
750
|
+
document rather than `$ref`-ing an external `.avsc` — external references are out of scope here
|
|
751
|
+
exactly as on the OpenAPI axis, so a schema in another file will not travel with the message that
|
|
752
|
+
needs it.
|
|
753
|
+
|
|
754
|
+
**The direction is not decoration, and the check inverts.** On the API axis the PROVIDER owns the
|
|
755
|
+
contract, so "does this operation exist" is answered inside one service's directory. An event's
|
|
756
|
+
schema lives in the PRODUCER's repository, so "does anybody publish what I consume" is a fleet
|
|
757
|
+
question with no local answer — which is why `spine.message-unproduced` has no OpenAPI analog, and
|
|
758
|
+
why a consumer joined to nothing was previously indistinguishable from a healthy one. A producer
|
|
759
|
+
OUTSIDE the fleet is expressible: an `#external` element whose edge carries
|
|
760
|
+
`metadata { publishes '<msg>' }` is a recognized producer, `spine.message-unproduced` stays quiet,
|
|
761
|
+
and the contract question shifts to the consumer's own `asyncapi.yaml` — `spine.message-external`
|
|
762
|
+
(warn) fires while that file defines no shape for the message and stops once it does. Its other half
|
|
763
|
+
is `asyncapi.message-contested`: two services declaring they send one name means every consumer's
|
|
764
|
+
join picks one arbitrarily. That one fleet question is also the axis's only cost — a per-service run
|
|
765
|
+
reads every service's `asyncapi.yaml`, which is N small YAML parses rather than N LikeC4 workspace
|
|
766
|
+
spins, and it is paid only where something actually consumes.
|
|
767
|
+
|
|
768
|
+
**Absence has two grades, not three.** `service.no-openapi` warns when the landscape cannot prove
|
|
769
|
+
nobody calls a service, because most services expose HTTP. An event contract is genuinely optional
|
|
770
|
+
and most services touch no topic, so an absent `asyncapi.yaml` is an **error** when something
|
|
771
|
+
already joins into it (the stranded message names ride in `details`) and **silence** otherwise. The
|
|
772
|
+
file is presence-tracked in `loam list` and deliberately kept out of the maturity ladder: a fourth
|
|
773
|
+
required artifact would demote every already-`documented` service in the fleet without one byte of
|
|
774
|
+
their files changing.
|
|
775
|
+
|
|
776
|
+
**Draw the topic, not the broker.** An edge may point wherever it likes and every check joins the
|
|
777
|
+
same way — an endpoint inside a nested element resolves to the element that owns it — so this is a
|
|
778
|
+
modelling convention rather than a rule loam enforces, and it is on the
|
|
779
|
+
[unchecked list](https://github.com/ybotok/loam/blob/main/src/core/brief/unchecked.ts) with the
|
|
780
|
+
reason spelled out. It matters anyway because it is the one modelling mistake whose cost is
|
|
781
|
+
invisible until it is expensive: a broker drawn as ONE element is the node every service in the
|
|
782
|
+
fleet points at, so a fleet map that read fine at five services is a star with sixty spokes at
|
|
783
|
+
sixty, and nothing warns on the way there because loam parses the model and renders no view. Nesting
|
|
784
|
+
a topic per channel inside the broker's element and pointing the edges at `kafka.<topic>` splits one
|
|
785
|
+
hub into a dozen small ones, and it is the truer model besides: what a producer and a consumer share
|
|
786
|
+
is the channel, never the server. Declare the kind with the tag on it —
|
|
787
|
+
`element topic { #external }` — because LikeC4 does not inherit tags, and an untagged topic under an
|
|
788
|
+
`#external` broker is a `landscape.service-undocumented` asking for a `services/` directory nobody
|
|
789
|
+
owes. The
|
|
790
|
+
[worked landscape](https://github.com/ybotok/loam/blob/main/examples/docs/architecture/landscape.likec4)
|
|
791
|
+
includes three views (`exclude element.tag = #external` for the synchronous map, one for the event
|
|
792
|
+
spine, one over `#platform`) that keep the picture readable once the model is. It also puts
|
|
793
|
+
`#platform` on the topic KIND rather than on `kafka` alone, which is what the split makes necessary:
|
|
794
|
+
`landscape.platform-candidate` grades each drawn element by how many services consume it, and once
|
|
795
|
+
the edges point at channels it is the channels that collect three consumers each — tagging only the
|
|
796
|
+
broker leaves a row of untagged hubs behind it.
|
|
797
|
+
|
|
798
|
+
**The feature lifecycle, mirrored from the OpenAPI axis.** A feature changes this contract through
|
|
799
|
+
`features/<FEAT>/specs/<svc>/asyncapi.yaml` — a **complete AsyncAPI 3.0 document**, exactly as an
|
|
800
|
+
openapi delta is a complete document: the author restates the living contract around the part they
|
|
801
|
+
are changing, and the machinery tells restatement from edit. Identity is the **slot** — the pair
|
|
802
|
+
(section, key) over the three named sections `channels.<key>`, `operations.<key>` and
|
|
803
|
+
`components.messages.<key>` — and the join token stays the message name. Three feature-only keys,
|
|
804
|
+
spelled and shaped exactly as on the OpenAPI axis: `x-loam-based-on` inside a slot's value (written
|
|
805
|
+
by `loam rebase`; the same 16-hex sha256, taken over canonical JSON of the slot's value with the pin
|
|
806
|
+
itself excluded) records which living version of the slot the delta was written against, so the
|
|
807
|
+
merge can tell a **quote** (equal to its own pin — never a merge input) from an **edit** (differs —
|
|
808
|
+
written); `x-loam-remove: true` inside a slot's value retires that slot at archive; and the root
|
|
809
|
+
record **`x-loam-baselines`** (`{ components: {"<kind>/<name>": <16-hex>} }`) pins what an in-value
|
|
810
|
+
key cannot reach — every `components.<kind>.<name>` **outside `messages`**, which is a **surface**
|
|
811
|
+
rather than a slot. A `components.schemas` value is a JSON Schema, where an in-value loam key would
|
|
812
|
+
be a schema keyword and where JSON Schema's own `true` is a legal component no in-value key survives
|
|
813
|
+
at all; `components.messages` is deliberately absent from the record, because a message already
|
|
814
|
+
carries its own pin and two identities for one value would be two verdicts that need not agree.
|
|
815
|
+
Surfaces are merged, graded and reported exactly as slots are — a quote is skipped, an edit is
|
|
816
|
+
written and warned as `asyncapi.component-modified`, an unpinned restatement counts into the same
|
|
817
|
+
per-service `asyncapi.baseline-missing`, a stale entry is `asyncapi.baseline-stale`, and a malformed
|
|
818
|
+
record or an entry naming nothing the delta declares is `asyncapi.baseline-invalid`. Nothing
|
|
819
|
+
under `payload` ever contributes a join token: a slot digest hashes payload bytes as **content
|
|
820
|
+
identity only** — equality, never a join on payload fields — so Avro stays a `schemaFormat` line.
|
|
821
|
+
One honest consequence: a `schemaFormat` migration changes payload bytes, so even quoted slots
|
|
822
|
+
surface `asyncapi.baseline-stale` — that is the pin reporting a real content change under a delta
|
|
823
|
+
that restates it, not a false positive; re-read the living slot and `loam rebase`. The same is true
|
|
824
|
+
of a surface: a migration that rewrites a `components.schemas` entry moves its digest, so a delta
|
|
825
|
+
that merely restates the schema reports the change rather than hiding it, and the fix is the same
|
|
826
|
+
one command.
|
|
827
|
+
|
|
828
|
+
**Inline channel messages are channel interior, by decision.** A message declared inline at
|
|
829
|
+
`channels.<ck>.messages.<mk>` still joins the spine (the reader declares it as a first-class
|
|
830
|
+
message), but its identity under the feature lifecycle is **part of the `channels.<ck>` slot's
|
|
831
|
+
content**: it is not a slot of its own, it cannot carry its own pin, and the slot walker never
|
|
832
|
+
visits it. A feature delta that declares or retires an **individual** message must do so under
|
|
833
|
+
`components.messages` (the channel entry aliasing it with a `$ref`); retiring a whole channel
|
|
834
|
+
retires its inline interior with it — the channel's own `x-loam-remove: true` **is** the removal
|
|
835
|
+
marker for every message the living channel declares inline, so it satisfies a justifying `REMOVED`
|
|
836
|
+
requirement's marker debt and the retirement is gated on consumers exactly as a message-slot marker
|
|
837
|
+
is. An `x-loam-remove` nested on an inline message is graded as part of the channel slot's content —
|
|
838
|
+
it makes the channel an edit, never the message a removal — and since the removal it asks for would
|
|
839
|
+
silently not happen, `loam archive` refuses it by name (`asyncapi.remove-marker-inline`, error,
|
|
840
|
+
overridable with `--approve`); the merge strips loam's two keys at that nested depth exactly as at
|
|
841
|
+
slot depth either way, so a nested marker can never reach the living contract inside a channel that
|
|
842
|
+
merges.
|
|
843
|
+
|
|
844
|
+
**This supersedes the recorded decision against a removal family.** An earlier revision of this
|
|
845
|
+
section declared deprecation markers and removals absent rather than pending, on the consumer-lag
|
|
846
|
+
argument: a consumer reading a topic with lag breaks on a removed field regardless of what the
|
|
847
|
+
document says about it. The roadmap's exit criterion for completing this axis — retiring a message
|
|
848
|
+
identity through a feature delta, gated on nothing living still consuming it — is the compatibility
|
|
849
|
+
decision that reverses that stance: the removal family exists, a message-slot removal must be
|
|
850
|
+
justified by a `REMOVED` requirement's `Publishes:`/`Consumes:` line (channel and operation slots
|
|
851
|
+
need exactness only — requirements join on message names and never on channel or operation keys, so
|
|
852
|
+
demanding a justifying requirement there would invent a join the corpus cannot express), and
|
|
853
|
+
`asyncapi.remove-message-consumed` gates a removal the living fleet still consumes. The lag argument
|
|
854
|
+
survives as an **operational warning**, not a veto: passing the gate proves the *documents* agree
|
|
855
|
+
nobody consumes the message — it does not prove no consumer is still reading the topic with lag, so
|
|
856
|
+
sequence the runtime retirement accordingly.
|
|
857
|
+
|
|
858
|
+
**Payload-schema correctness stays out of scope, stated so the boundary reads as a decision rather
|
|
859
|
+
than a gap.** loam never validates that a payload schema is correct JSON Schema, correct Avro, or
|
|
860
|
+
correct anything: digests are content identity and every check joins on names. A fleet that wants
|
|
861
|
+
payload validation runs an external validator (for example `@asyncapi/parser` in CI) over the same
|
|
862
|
+
files, outside loam — no runtime dependency, and nothing in the lifecycle above depends on its
|
|
863
|
+
verdict.
|
|
864
|
+
|
|
865
|
+
### The authorization vocabulary (`architecture/permissions.yaml`)
|
|
866
|
+
|
|
867
|
+
Authorization is a fleet-owned vocabulary joined to requirements by `Requires:`. It is opt-in: a
|
|
868
|
+
fleet with no `Requires:` line owes no file, but the first such line makes an absent declaration an
|
|
869
|
+
error rather than inventing a permission locally.
|
|
139
870
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
871
|
+
```yaml
|
|
872
|
+
subjects:
|
|
873
|
+
user:
|
|
874
|
+
description: a human account
|
|
875
|
+
permissions:
|
|
876
|
+
user:
|
|
877
|
+
payments:refund:
|
|
878
|
+
description: refund a captured payment
|
|
879
|
+
owned_by: identity-service
|
|
880
|
+
enforced_by: [payment-service]
|
|
881
|
+
```
|
|
143
882
|
|
|
144
|
-
|
|
883
|
+
The unit is the pair `<subject>/<permission>`: `Requires: user/payments:refund`. A requirement may
|
|
884
|
+
list several comma-separated pairs. The subject is the entity the check is performed on (`user`,
|
|
885
|
+
`profile`, `service`), not necessarily the caller.
|
|
145
886
|
|
|
146
|
-
|
|
887
|
+
- `permissions.unknown` (**error**) — a requirement names a pair the vocabulary does not declare;
|
|
888
|
+
when the file is absent, the `Requires:` line is the opt-in and the message asks for the first
|
|
889
|
+
declaration.
|
|
890
|
+
- `permissions.invalid` (**error**) — the file is unreadable or a permission sits under an
|
|
891
|
+
undeclared subject. It is reported alone because grading every `Requires:` against an unreadable
|
|
892
|
+
vocabulary would be a cascade.
|
|
893
|
+
- `permissions.unenforced` (**warning**, fleet scope) — a declared permission no requirement names,
|
|
894
|
+
the authorization mirror of `api.ungoverned`.
|
|
147
895
|
|
|
148
|
-
|
|
896
|
+
This is a spelling and coverage join, not an identity-provider audit. `owned_by` and `enforced_by`
|
|
897
|
+
are explanatory fields and are not resolved against service directories; loam does not prove that
|
|
898
|
+
the running system implements a declared permission.
|
|
149
899
|
|
|
150
|
-
**Page-specs** (`ui/pages/*.page.yaml`) are `[later]`: an example ships under `examples/docs/`, but
|
|
900
|
+
**Page-specs** (`ui/pages/*.page.yaml`) are `[later]`: an example ships under `examples/docs/`, but
|
|
901
|
+
no command reads pages yet, so the file's format is illustrative rather than contract. What is
|
|
902
|
+
settled is only the job — a page names the operations it consumes and the scenarios that govern it,
|
|
903
|
+
joining the same spine as everything else.
|
|
151
904
|
|
|
152
905
|
## Living spec vs delta (OpenSpec model)
|
|
153
906
|
|
|
154
|
-
Behaviour follows OpenSpec conventions: a **requirement** (`### Requirement:`, RFC-2119 SHALL/MUST)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
907
|
+
Behaviour follows OpenSpec conventions: a **requirement** (`### Requirement:`, RFC-2119 SHALL/MUST)
|
|
908
|
+
with **scenarios** (`#### Scenario:`, Given/When/Then). Scenarios are the acceptance criteria and
|
|
909
|
+
the source for tests.
|
|
910
|
+
|
|
911
|
+
- **Living spec** — `services/<svc>/spec.md` (+ `landscape.likec4`): the complete current state —
|
|
912
|
+
the "final spec of the whole product".
|
|
913
|
+
- **Delta** — `features/<FEAT>/specs/<svc>/spec.md` (+ `delta.likec4`): a change, reviewed as a
|
|
914
|
+
diff, tagged to the feature.
|
|
915
|
+
- **`loam archive <FEAT>`** merges the delta into the living state on three axes — **requirements**
|
|
916
|
+
(`spec.md`, and `arch.spec.md` beside it: the two files ride one merge code path, parameterized by
|
|
917
|
+
filename), **API** (`openapi.yaml`), **architecture** (`landscape.likec4`) — then archives the
|
|
918
|
+
feature, so the living state stays complete. Archived deltas are the evolution history (like
|
|
919
|
+
`git log`). `--dry-run` prints the whole plan and writes nothing.
|
|
920
|
+
- **Coherence gate:** `loam validate --feature` checks the three axes agree (C4 edge `op` ↔ OpenAPI
|
|
921
|
+
`operationId` ↔ requirement `Operations:`). `loam archive` **blocks on the gating issues**.
|
|
922
|
+
Severity and gating answer two different questions — severity says whether the *document* is valid
|
|
923
|
+
(`validate` fails on errors), gating says whether the *merge* is safe — and they usually agree:
|
|
924
|
+
errors gate, warnings do not. Where they diverge, the finding says so (`gates` in `--json`); the
|
|
925
|
+
warn-that-gates set holds five codes today — `delta.requirement-not-merged` (the shape is legal
|
|
926
|
+
OpenSpec, but the merge would silently drop the requirement), `delta.baseline-missing` and
|
|
927
|
+
`openapi.baseline-missing` (an unpinned delta is the silent-rollback shape — the baseline bullets
|
|
928
|
+
below), and `scaffold.placeholder` and `intent.empty` (an unedited scaffold must not become living
|
|
929
|
+
truth) — each a legal document whose merge would publish or destroy something nobody chose.
|
|
930
|
+
Advisory warnings are printed with the plan and never block. `--approve` overrides the gating
|
|
931
|
+
issues only, and names each one it overrode. An operation that is missing from the provider's
|
|
932
|
+
OpenAPI but defined by another feature still in flight is graded down to a warning
|
|
933
|
+
(`spec-api.op-pending` / `c4-api.op-pending`) naming that feature — archive it first — because the
|
|
934
|
+
fix is ordering, not authoring.
|
|
935
|
+
- **`loam unarchive <FEAT>`** takes an archive back: it restores the living docs and re-opens the
|
|
936
|
+
feature.
|
|
937
|
+
- **The requirement baseline.** A MODIFIED requirement carries its full new text rather than a diff,
|
|
938
|
+
so the merge REPLACES the living requirement instead of folding into it — and two features
|
|
939
|
+
rewriting one requirement destroy the loser's text entirely. `loam validate` names the other
|
|
940
|
+
feature while both are in flight (`delta.modified-conflict`, warn), but that check is blind to the
|
|
941
|
+
case a fleet actually produces: the first feature archives and stops being active, the second
|
|
942
|
+
revalidates green, and its archive lands text written against a document that no longer exists —
|
|
943
|
+
`+0 ~1 -0`, exit 0, undetectable afterwards (`unarchive` of the first then refuses
|
|
944
|
+
`snapshot-stale`, and `--force` takes the second out with it). So a MODIFIED/REMOVED requirement
|
|
945
|
+
pins what it was written against with a **`Based-On:`** body line under its `Requirement-ID:` —
|
|
946
|
+
`requirementDigest` of the living requirement, the same 16-hex-character truncation
|
|
947
|
+
`sources_digest` uses. It is written by **`loam rebase <FEAT> [--service <id>] [--dry-run]`**,
|
|
948
|
+
never by hand: rebase takes the docs lock, computes each pin from the living text as it stands,
|
|
949
|
+
and rewrites only that one line per requirement (line surgery, not reserialization — a delta
|
|
950
|
+
document's sections, order and prose are authored content). The digest is taken over the
|
|
951
|
+
requirement's *canonical serialization*, not its bytes, so archive's own reflowing of a living
|
|
952
|
+
spec never makes a pin go stale falsely. `delta.baseline-stale` is an error and gates archive:
|
|
953
|
+
there is no reading of a stale delta under which the merge is what its author meant.
|
|
954
|
+
`delta.baseline-missing` is a warning that also gates: the document is legal — deltas adopted from
|
|
955
|
+
OpenSpec never carried the line, so `loam validate` stays green — but an unpinned MODIFIED/REMOVED
|
|
956
|
+
is exactly the shape that replaces whatever landed in between, so the archive refuses it. A
|
|
957
|
+
migrated corpus is not stranded: one `loam rebase <FEAT>` per staged feature pins everything
|
|
958
|
+
(already the documented migration follow-up), and `--approve` archives unpinned when the loss is
|
|
959
|
+
meant. `Based-On:` is stripped on the way into the living document: it is a claim about a delta,
|
|
960
|
+
meaningless once it *is* the living text, and leaving it in would make the next feature's baseline
|
|
961
|
+
hash the previous feature's bookkeeping. Re-pinning is the last step of resolving a collision and
|
|
962
|
+
never the resolution itself — a pin says "I read this version", so `loam rebase` reports every
|
|
963
|
+
requirement that moved and says plainly that restamping without re-reading merges your text over
|
|
964
|
+
theirs. There is deliberately no three-way merge: requirement prose does not merge.
|
|
965
|
+
- **The same baseline on the contract axis, where it matters more.** A feature's `openapi.yaml` is a
|
|
966
|
+
**complete document, not a patch** — authors restate the living contract around the slot they
|
|
967
|
+
change — and the merge upserts every operation the document spells. The collision therefore needs
|
|
968
|
+
no overlap between the two features at all: two deltas over one service, editing *different*
|
|
969
|
+
operations, destroy each other, because whichever archives second pushes its authoring-time copy
|
|
970
|
+
of the operation it never meant to touch back over what landed in between. On a service with
|
|
971
|
+
thirty operations a delta quotes twenty-nine of them, and each is a candidate. So every operation
|
|
972
|
+
in a feature contract carries **`x-loam-based-on`** — the same 16-hex digest, in the
|
|
973
|
+
vendor-extension shape this axis already uses for `x-loam-remove`, written by `loam rebase` and
|
|
974
|
+
stripped on merge exactly like `Based-On:`. The digest is taken over the operation's *canonical*
|
|
975
|
+
form (object keys sorted), which is the same comparison `isDeepStrictEqual` makes when the merge
|
|
976
|
+
decides an operation "differs" — so a pin goes stale exactly when the merge would say something
|
|
977
|
+
moved, never over a reordered key. The pin is always the digest of the **living** operation, and
|
|
978
|
+
that one rule yields both merge verdicts by itself: an operation the author only quoted is
|
|
979
|
+
byte-equal to living, so its pin equals its own content and **the merge skips it entirely**
|
|
980
|
+
(reported as `· quotes …` in the plan, so the plan never silently writes less than the delta
|
|
981
|
+
spells); one the author edited differs from its pin, so the merge writes it — unless the living
|
|
982
|
+
operation has also moved, which is `openapi.baseline-stale` (error, gates). Skipping a quote is
|
|
983
|
+
mechanical rather than advisory, so `--approve` does not turn it back on: overriding a gate is a
|
|
984
|
+
decision, reverting an operation nobody edited is a bug. `openapi.baseline-missing` is a warning
|
|
985
|
+
that gates the archive, exactly like its requirement-axis sibling — every unpinned restatement is
|
|
986
|
+
a candidate rollback — and it is reported **once per service** rather than per operation: the fix
|
|
987
|
+
is one command, and twenty-nine identical findings teach people to filter the code out. **Covered
|
|
988
|
+
since the path-item/component baselines landed:** the same discipline now reaches the two surfaces
|
|
989
|
+
the operation pin cannot — path-level keys (`parameters`, `servers`, `summary`, `x-*`) and every
|
|
990
|
+
`components/<kind>/<name>` — through a single feature-only root record **`x-loam-baselines`** (`{
|
|
991
|
+
pathItems: {<path>: {<key>: <16-hex>}}, components: {"<kind>/<name>": <16-hex>} }`), written by
|
|
992
|
+
`loam rebase`, stripped from every living document, and graded through the same three codes: an
|
|
993
|
+
unpinned restatement counts into the per-service `openapi.baseline-missing` warn, a stale one is
|
|
994
|
+
`openapi.baseline-stale`, a malformed record or an entry naming nothing the delta restates is
|
|
995
|
+
`openapi.baseline-invalid`. The identity of a path-item field is `(path, key)` plus the
|
|
996
|
+
canonical-form digest of its value; a component's is `(kind, name)` likewise — recursion is
|
|
997
|
+
deliberately NOT part of identity: each component carries its own pin, and reference closure only
|
|
998
|
+
ever decides copying. A quoted surface is skipped by the merge exactly as a quoted operation is;
|
|
999
|
+
component copying is verdict-driven, with genuinely new components copied only when reachable from
|
|
1000
|
+
content actually written. Removal markers still carry no pin, since
|
|
1001
|
+
`openapi.remove-target-mismatch` already checks that exact slot.
|
|
1002
|
+
|
|
1003
|
+
A requirement may carry a stable identity in its body: `Requirement-ID: <id>`, where the ID matches
|
|
1004
|
+
`[A-Za-z][A-Za-z0-9._-]{0,127}`. It is optional for backward compatibility. When present in a delta,
|
|
1005
|
+
archive matches the living requirement by ID; a `MODIFIED` block may therefore change the
|
|
1006
|
+
`### Requirement:` heading without losing history or verification evidence. When absent, the exact
|
|
1007
|
+
heading remains the legacy identity. A legacy `MODIFIED` that matches an ID-bearing living
|
|
1008
|
+
requirement by name inherits that ID, which supports incremental adoption. `Requirement-ID` may
|
|
1009
|
+
appear only once per requirement and must be unique within each `spec.md` / `arch.spec.md`; invalid,
|
|
1010
|
+
repeated, duplicate, or ID-versus-heading collisions are errors, and the merge refuses to guess.
|
|
1011
|
+
This is loam's explicit rename mechanism: stable ID plus `MODIFIED`, not OpenSpec's `RENAMED`
|
|
1012
|
+
section.
|
|
1013
|
+
|
|
1014
|
+
Two `### Requirement:` blocks with one name in one LIVING document are still an **error**
|
|
1015
|
+
(`spec.duplicate-requirement`, per file — `spec.md` and `arch.spec.md` are separate namespaces, so
|
|
1016
|
+
one name in both is legal): a legacy name-based merge cannot choose safely. A repeated `Operations:`
|
|
1017
|
+
or `Covers:` line inside one requirement body is a **warning** (`spec.repeated-operations` /
|
|
1018
|
+
`spec.repeated-covers`, living specs and feature deltas alike): the last line REPLACES the earlier
|
|
1019
|
+
ones — assignment, not append, the documented keep-last quirk, whose semantics deliberately do not
|
|
1020
|
+
change — so the author's "long list in two lines" pattern silently loses its first line; merge them
|
|
1021
|
+
into one comma-separated line.
|
|
167
1022
|
|
|
168
1023
|
### The architecture spec axis (`arch.spec.md`)
|
|
169
1024
|
|
|
170
|
-
The business spec will never mention the transactional outbox — that is architecture. Retries,
|
|
171
|
-
|
|
172
|
-
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
1025
|
+
The business spec will never mention the transactional outbox — that is architecture. Retries,
|
|
1026
|
+
idempotency, metrics, alerts: real obligations, invisible to every business scenario, and exactly
|
|
1027
|
+
where agent-generated code cuts corners unless the obligations are derived mechanically. So they get
|
|
1028
|
+
their own spec file, in the same grammar:
|
|
1029
|
+
|
|
1030
|
+
- **Living** — `services/<svc>/arch.spec.md`: the architectural requirements as they stand, under
|
|
1031
|
+
`## Requirements`.
|
|
1032
|
+
- **Delta** — `features/<FEAT>/specs/<svc>/arch.spec.md`: the change, under the same
|
|
1033
|
+
`## ADDED|MODIFIED|REMOVED Requirements` algebra.
|
|
1034
|
+
|
|
1035
|
+
Frontmatter follows `spec.md`'s conventions exactly (`service`, `status`, `sources` — the same
|
|
1036
|
+
provenance pass reads both, only the label differs), and `loam vouch` stamps the pair in one
|
|
1037
|
+
all-or-nothing run when this file is present. **Absence is not a finding**: partial adoption is
|
|
1038
|
+
supported, and the obligations below fire only when there is something to cover.
|
|
1039
|
+
|
|
1040
|
+
**The `Covers:` line** is the architecture analog of `Operations:` — where a business requirement
|
|
1041
|
+
declares the operations it governs, an architecture requirement declares the model objects its
|
|
1042
|
+
scenarios exercise. Comma-separated, same keep-last-line quirk (and the same repeated-line warning,
|
|
1043
|
+
`spec.repeated-covers`), three entry forms:
|
|
1044
|
+
|
|
1045
|
+
- a **C4 element** — its id (`paymentService.db`), or the service a bound/titled element stands for;
|
|
1046
|
+
resolved against the service's own model plus the landscape (for a feature delta: the feature's
|
|
1047
|
+
`delta.likec4`, the landscape, and the service's model);
|
|
1048
|
+
- an **edge** — `paymentService -> kafka`, each side resolved the same way against the declared
|
|
1049
|
+
relationships;
|
|
181
1050
|
- a **health signal** — `alert:<id>` / `sli:<id>`, ids the service's `health.yaml` declares.
|
|
182
1051
|
|
|
183
|
-
An entry that resolves to nothing is `covers.unknown` (**warning**, with close ids offered where
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
1052
|
+
An entry that resolves to nothing is `covers.unknown` (**warning**, with close ids offered where
|
|
1053
|
+
they exist): the typo guard, because a mistyped entry silently costs exactly the coverage it was
|
|
1054
|
+
written for.
|
|
1055
|
+
|
|
1056
|
+
**Coverage obligations** — both warnings, deliberately: they never gate `archive`, and `--strict` is
|
|
1057
|
+
the CI escalation.
|
|
1058
|
+
|
|
1059
|
+
- `c4.uncovered` (feature scope): a NEW tagged element or tagged edge in the feature's
|
|
1060
|
+
`delta.likec4` that no requirement across the feature's `arch.spec.md` deltas covers.
|
|
1061
|
+
Grouping-only elements follow the landscape checks' exemptions (`person`-kind elements,
|
|
1062
|
+
`#external` tags).
|
|
1063
|
+
- `health.uncovered` (service scope): an alert or SLI declared in `services/<svc>/health.yaml` that
|
|
1064
|
+
no requirement in the LIVING `arch.spec.md` covers. This is the moment `health.yaml` stops being
|
|
1065
|
+
inert — and all loam reads out of it is ids: the recognized keys are top-level `slis:` and
|
|
1066
|
+
`alerts:`, each a sequence whose entries contribute their `name` (or `id` when there is no name; a
|
|
1067
|
+
plain string entry is its own id), and top-level `dependencies:`, whose entries contribute their
|
|
1068
|
+
`id` (or `service`, or `name` — both spellings exist in the wild; a plain string entry is its own
|
|
1069
|
+
id). Queries, thresholds, checks, criticality stay authored prose no check reads. A `health.yaml`
|
|
1070
|
+
that is missing, or declares nothing recognizable, yields no findings — an absent file must not
|
|
1071
|
+
manufacture obligations. One that EXISTS but does not parse is different evidence and gets its own
|
|
1072
|
+
**warning** (`health.invalid`): its ids are unknown, not empty, so `health.uncovered` stays quiet
|
|
1073
|
+
AND the `Covers: alert:/sli:` entries of that service go ungraded — the old silence turned every
|
|
1074
|
+
such entry into a false `covers.unknown` "typo" pointing at the spec, when the truth was the file.
|
|
1075
|
+
- `health.dependency-unmodelled` (service scope): a `dependencies:` id that nothing in the service's
|
|
1076
|
+
OWN `model.likec4` answers to — by element id, `metadata { service }` binding, or title; exact
|
|
1077
|
+
match, with a did-you-mean hint. Deliberately the service model and never the landscape: the
|
|
1078
|
+
landscape carries only what crosses the service boundary, the model carries everything the service
|
|
1079
|
+
touches (a private store as a nested container — `landscape.datastore-private` is the check
|
|
1080
|
+
pushing it there), and that is the set the on-call file must agree with.
|
|
1081
|
+
|
|
1082
|
+
The rest of the machinery treats the axis as what it is — requirements. `loam archive` merges an
|
|
1083
|
+
`arch.spec.md` delta into the living one through the same code path as `spec.md` (same delta
|
|
1084
|
+
algebra, same prose-preserving rewrite, same guards and delta-shape checks — the two files are
|
|
1085
|
+
separate requirement namespaces, so an arch requirement never collides with a business one of the
|
|
1086
|
+
same name); the snapshot covers it and `unarchive` restores it. `loam verify` derives
|
|
1087
|
+
`scenario.tested` claims from ADDED/MODIFIED arch requirements exactly as from business ones — the
|
|
1088
|
+
claim id, the claim text AND the scenario digest carry `arch.spec.md` (the arch axis salts the body
|
|
1089
|
+
hash), so an identically-worded scenario in both files stays two questions with two digests, and the
|
|
1090
|
+
answering agent knows an integration/ops test is being asked for. `loam delta --json` projects arch
|
|
1091
|
+
requirement deltas as `archRequirements`, the same item shape as `requirements` (each item also
|
|
1092
|
+
carries `covers`).
|
|
1093
|
+
|
|
1094
|
+
**Test levels, mapped once:** a business scenario is an acceptance test — emitted as a generated
|
|
1095
|
+
`.feature` by `loam gherkin`; an arch scenario is an integration or operational test (the outbox
|
|
1096
|
+
relay, the retry, the alert rule) — the same emission, tagged `@architecture`; `api.exposes` is a
|
|
1097
|
+
contract test; unit tests sit below spec granularity and stay the coding agent's TDD concern.
|
|
193
1098
|
|
|
194
1099
|
### The generated Gherkin suite (`loam gherkin`)
|
|
195
1100
|
|
|
196
|
-
Scenarios are the source for tests, and `loam gherkin` is where that stops being prose: it emits
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
|
|
211
|
-
**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
1101
|
+
Scenarios are the source for tests, and `loam gherkin` is where that stops being prose: it emits
|
|
1102
|
+
them as real `.feature` files into the SERVICE'S repo — the only loam command that writes there,
|
|
1103
|
+
because tests live with the code they gate. The output root is `<gherkinDir>/loam/`, where
|
|
1104
|
+
`gherkinDir` is an optional `loam.json` fact (default `features`, the cucumber convention; a
|
|
1105
|
+
non-string refuses the config like a malformed `docsDir`). The `loam/` subdirectory is loam's own
|
|
1106
|
+
derived space, and the ownership rule is absolute both ways: regenerating a scope rewrites that
|
|
1107
|
+
scope's files and deletes its orphans (reported), and loam never writes or deletes a byte outside
|
|
1108
|
+
`loam/` — step definitions and hand-written features live outside it, untouched. An emission with
|
|
1109
|
+
nothing to emit creates nothing: an empty `loam/` would read as a whole suite gone missing.
|
|
1110
|
+
|
|
1111
|
+
Two scopes, one emitter, run inside the service's repo (anywhere else refuses — vouch's discipline,
|
|
1112
|
+
because the files land where loam is standing):
|
|
1113
|
+
|
|
1114
|
+
- **Feature mode** — `loam gherkin <FEAT> [--service <id>]`: the feature's ADDED and MODIFIED
|
|
1115
|
+
requirements for that service, both spec axes, each file tagged with the feature id.
|
|
1116
|
+
- **Living mode** — `loam gherkin --service <id>`: the full acceptance suite from the living
|
|
1117
|
+
`spec.md` + `arch.spec.md` — the regression skeleton a legacy service gets at adoption.
|
|
1118
|
+
|
|
1119
|
+
**The mapping is deterministic** — same specs, same bytes, which is what lets staleness be judged by
|
|
1120
|
+
digest:
|
|
1121
|
+
|
|
1122
|
+
- one `.feature` per requirement: `Feature:` is the requirement name, the requirement's body text is
|
|
1123
|
+
the feature description, its scenarios are `Scenario:` blocks;
|
|
1124
|
+
- file naming: `<slug>.feature` (business axis) / `arch--<slug>.feature` (arch axis) — flat and
|
|
1125
|
+
diffable. Slugs lowercase the name and collapse every non-alphanumeric run to one hyphen, so no
|
|
1126
|
+
business slug can ever begin `arch--`; requirements slugging identically are numbered in document
|
|
1127
|
+
order (`retry.feature`, `retry-2.feature`);
|
|
1128
|
+
- steps: a scenario body line that is a list bullet (`-`, `*`, `+`) whose text — after stripping the
|
|
1129
|
+
marker and a `**bold**` wrapper around the keyword, trailing colon tolerated — opens with
|
|
1130
|
+
Given/When/Then/And/But (case-insensitive, so the OpenSpec `- **WHEN** ...` convention counts)
|
|
1131
|
+
becomes that keyword's step. Every other non-blank body line is kept, edge-trimmed, as the
|
|
1132
|
+
scenario's description, rendered before the steps (Gherkin ends a description at the first step
|
|
1133
|
+
keyword) — prose is never dropped. A scenario that yields ZERO steps (numbered-step or prose-only
|
|
1134
|
+
legacy bodies) still emits, but the emission says so per scenario (text, and `stepless` per file
|
|
1135
|
+
under `--json`): cucumber runs a step-less scenario vacuously green while `verify --results`
|
|
1136
|
+
requires at least one passed step, so it is permanently unconfirmable until the spec's bullets are
|
|
1137
|
+
reworded;
|
|
1138
|
+
- examples: a Markdown table **at the body’s own left margin** becomes `Examples:` and changes the
|
|
1139
|
+
emission to `Scenario Outline`; its header names the `<placeholders>` used by steps and each row is
|
|
1140
|
+
one case. The header needs at least one data row and every row needs the same column count, and a
|
|
1141
|
+
cell may hold a literal pipe by writing `|`. A malformed table is preserved as description instead
|
|
1142
|
+
of being dropped, and the command reports that scenario in `malformedExamples` because it will run
|
|
1143
|
+
once rather than once per row. Only the FIRST margin table is `Examples`;
|
|
1144
|
+
- step arguments — the two forms a dense component test needs, both written UNDER the step they
|
|
1145
|
+
belong to and both riding into the scenario digest like every other body byte:
|
|
1146
|
+
- an **indented** Markdown table becomes that step’s Gherkin **data table** (the markdown separator
|
|
1147
|
+
row is dropped; Gherkin has no such row). Indentation is the whole disambiguation: indented under
|
|
1148
|
+
a step means “this step’s rows”, at the margin means “the scenario’s cases”. This is how an
|
|
1149
|
+
expected-state assertion — what a row, a topic or an outbox must hold after one data pass — is
|
|
1150
|
+
written at all;
|
|
1151
|
+
- a **fenced block** (```` ``` ```` or `~~~`, with its info string carried through as the
|
|
1152
|
+
docstring’s content type) becomes that step’s **docstring**. Its lines keep their indentation
|
|
1153
|
+
rather than being trimmed like description — a payload whose leading whitespace has been eaten is
|
|
1154
|
+
one no step definition can compare against. Fences are recognised before tables, so a pipe table
|
|
1155
|
+
inside a payload is part of the payload and is never hoisted out as `Examples`;
|
|
1156
|
+
- a block that no step precedes, or an indented table loam cannot read, stays in the description and
|
|
1157
|
+
is reported per scenario as `strandedBlocks`: the document still reads correctly and the TEST has
|
|
1158
|
+
lost its argument, which is a loss nothing downstream can otherwise notice;
|
|
1159
|
+
- tags, on the `Feature:` line and inherited by its scenarios: `@<FEAT>` in feature mode (living
|
|
1160
|
+
emissions carry no feature tag), `@architecture` on the arch axis — the test-level marker;
|
|
1161
|
+
- stamps: line 1 is `# generated by loam v<version> — …` (the AGENTS.md stamp pattern — the file is
|
|
1162
|
+
never to be hand-edited), and each scenario carries the tag `@loam-digest-<16hex>` on the line
|
|
1163
|
+
above its `Scenario:` keyword: the first 16 hex characters of `loam verify`'s scenario body hash —
|
|
1164
|
+
sha256 of the body lines joined and edge-trimmed, salted with the file name on the arch axis, the
|
|
1165
|
+
exact recipe `scenario.tested` claim ids fold in — so the stamp, the claim and the spec can never
|
|
1166
|
+
disagree about what a scenario says, and identically-worded scenarios across `spec.md` and
|
|
1167
|
+
`arch.spec.md` can never share a digest (a business-axis test run cannot answer for an arch
|
|
1168
|
+
scenario). A tag rather than a comment because cucumber's JSON report carries tags per scenario
|
|
1169
|
+
(`elements[].tags`): the stamp rides through the runner untouched, which is what
|
|
1170
|
+
`loam verify --results` matches on.
|
|
1171
|
+
|
|
1172
|
+
**Staleness** (`loam validate --service <id>`, service-repo-scoped like the `sources.*` chain):
|
|
1173
|
+
fires only where the repo is known AND `<gherkinDir>/loam/` exists — a service that never generated
|
|
1174
|
+
has not opted in, and stays quiet. The living specs are the reference; digests decide content,
|
|
1175
|
+
requirement names decide identity, and every comparison is axis-scoped (the two spec files are two
|
|
1176
|
+
namespaces: an arch scenario's integration test is not answered by a business `.feature` spelling
|
|
1177
|
+
the same words). Three warnings — never gates, `--strict` is the CI escalation — plus
|
|
1178
|
+
`gherkin.current` (ok) when a suite exists and agrees:
|
|
1179
|
+
|
|
1180
|
+
- `gherkin.missing` — a living scenario whose digest no stamped scenario carries: the suite has no
|
|
1181
|
+
test for these words;
|
|
1182
|
+
- `gherkin.stale` — a stamped scenario whose digest matches no living scenario while its file's
|
|
1183
|
+
requirement still exists: the spec moved under the file. A reworded scenario reports stale +
|
|
1184
|
+
missing together, and one regeneration clears both;
|
|
1185
|
+
- `gherkin.orphaned` — a file whose requirement no longer exists in its axis's living spec, reported
|
|
1186
|
+
once per file: every scenario in it is moot together.
|
|
1187
|
+
|
|
1188
|
+
A digest is content identity within an axis, deliberately: two identically-worded scenarios of one
|
|
1189
|
+
axis share one, and one stamped copy covers both. A file tagged with a feature still **in flight**
|
|
1190
|
+
is exempt from stale/orphan grading — it answers to its feature's delta (`loam gherkin <FEAT>` is
|
|
1191
|
+
its regeneration) until the feature archives, at which point its requirements ARE living
|
|
1192
|
+
requirements, digests unchanged, and the same bytes grade current with no rewrite. The exemption
|
|
1193
|
+
guards the writer too: a living-mode `loam gherkin` neither deletes NOR overwrites an in-flight
|
|
1194
|
+
file, even when a MODIFIED requirement's living emission collides with its filename — the file is
|
|
1195
|
+
reported as kept (`action: "kept"` under `--json`), because replacing it would silently revert the
|
|
1196
|
+
delta's wording mid-flight. An abandoned feature's tag names nothing active, so its files fall to
|
|
1197
|
+
`gherkin.orphaned` and regeneration removes them. Mid-flight, a MODIFIED requirement's reworded
|
|
1198
|
+
living scenarios do report `gherkin.missing` — the suite tests the delta's words while the living
|
|
1199
|
+
spec still promises the old ones; that is true, and archiving the feature clears it.
|
|
1200
|
+
|
|
1201
|
+
The loop is closed end to end: generate → write step definitions (outside `loam/`) → run the suite
|
|
1202
|
+
with a JSON report — `cucumber-js --format json:report.json`, the CI recipe; cucumber-jvm, behave
|
|
1203
|
+
and SpecFlow emit the same format — → implement until green →
|
|
1204
|
+
`loam verify <FEAT> --service <id> --results report.json [--record rest.json]`, run in that
|
|
1205
|
+
service's own repo. The digest tags ride through the runner into the report, so the done-check's
|
|
1206
|
+
`scenario.tested` claims are answered by the green run itself; see "The verification record".
|
|
1207
|
+
|
|
1208
|
+
**How much glue that second step is, and how much of it is already written**, is `loam steps`. It
|
|
1209
|
+
counts the distinct step PHRASES a service's living scenarios collapse onto — `{n}`, `{s}` and `{p}`
|
|
1210
|
+
placeholders folded, keyword dropped, because a runner resolves `Given` and `When` through one
|
|
1211
|
+
registry — reports how few phrases cover 80% of every written step, and groups the near-duplicates
|
|
1212
|
+
that differ only by an article or a trailing clause. It reads the living specs, so it answers in a
|
|
1213
|
+
repo that has never run `loam gherkin`, and it emits no file and no finding.
|
|
1214
|
+
|
|
1215
|
+
`services/<svc>/steps.yaml` is the optional other half: **the step catalogue**, a `steps:` list of
|
|
1216
|
+
step texts recording which phrases the team has decided its suite defines. `loam steps` reports the
|
|
1217
|
+
written phrases against it — written-but-not-catalogued is the work-list, catalogued-but-not-written
|
|
1218
|
+
is a definition with nothing left to match — so the report becomes a diff against a decision instead
|
|
1219
|
+
of the same recount every time.
|
|
1220
|
+
|
|
1221
|
+
It is **authored, not emitted**, and that is the axis's one real design decision. loam does not read
|
|
1222
|
+
code and must not present a derived thing as truth, so what a team can honestly write down is which
|
|
1223
|
+
phrases they have AGREED to define; nothing here claims a step definition exists. It stays a line in
|
|
1224
|
+
YAML rather than a file per phrase for the rule the glossary and capability trees settled — an entry
|
|
1225
|
+
with prose gets a file, an entry without prose stays a line — and it lives in the docs repo rather
|
|
1226
|
+
than beside the code because `loam steps` needs no service repo to stand in, and because
|
|
1227
|
+
`<gherkinDir>/loam/` is written and deleted wholesale by `loam gherkin`. Entries are step TEXTS, not
|
|
1228
|
+
keys: the key is loam's normalisation, and asking an author to type it would make an internal
|
|
1229
|
+
spelling a hand-written contract. **The axis carries no finding code and `loam validate` never reads
|
|
1230
|
+
it** — a phrase written before its glue is the normal order of work, and a phrase-similarity warning
|
|
1231
|
+
is the kind of check a fleet turns off, taking the real findings beside it with it.
|
|
220
1232
|
|
|
221
1233
|
### The smallest legal feature
|
|
222
1234
|
|
|
223
|
-
A one-service bugfix needs
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
1235
|
+
A one-service bugfix needs two authored files: a non-empty `features/<FEAT>/intent.md`, and
|
|
1236
|
+
`features/<FEAT>/specs/<svc>/spec.md` holding one `MODIFIED` requirement with its scenarios. The
|
|
1237
|
+
requirement also needs a current `Based-On:` pin before archive; `loam rebase <FEAT>` writes it.
|
|
1238
|
+
`delta.likec4` and an OpenAPI delta may both be absent when architecture and contract are unchanged.
|
|
1239
|
+
Omitting or leaving the intent as scaffold text raises the gating `intent.empty` /
|
|
1240
|
+
`scaffold.placeholder` warning, so the old one-file shape is not archive-ready.
|
|
1241
|
+
|
|
1242
|
+
Each axis may be legitimately empty, and the checks grade absence differently from
|
|
1243
|
+
emptiness-by-accident:
|
|
1244
|
+
|
|
1245
|
+
- **`delta.likec4`** — absent, or present with an empty `model {}` (both verified: `delta.valid`, no
|
|
1246
|
+
landscape write in the plan): a behaviour fix moves no boxes and no edges, and the architecture
|
|
1247
|
+
merge simply has nothing to do. The line is drawn at content: a file that *declares* elements or
|
|
1248
|
+
relationships with none carrying the feature tag is an error (`delta.nothing-tagged`), because
|
|
1249
|
+
declared-but-untagged is almost always a forgotten tag, while declaring nothing is a legible
|
|
1250
|
+
statement that the architecture is unchanged.
|
|
1251
|
+
- **openapi delta** — absent whenever the feature adds no operations; a requirement change that
|
|
1252
|
+
stays inside the existing contract has no API axis to speak on.
|
|
1253
|
+
- **requirement delta** — the axis that is nearly always present, because a feature that changes no
|
|
1254
|
+
requirement changes no promised behaviour. The exception is architecture-only work: a new service
|
|
1255
|
+
in the C4 delta with no `specs/<svc>/spec.md` gets `service.no-requirement-delta` as a warning,
|
|
1256
|
+
not an error.
|
|
1257
|
+
|
|
1258
|
+
`intent.md` is where the "why" lives — the delta says what changed, and nothing else says what for —
|
|
1259
|
+
and the archive gate holds it to that: `intent.empty` (a warning that gates, `--approve`
|
|
1260
|
+
overridable) fires when the file is missing or says nothing outside the scaffold's own comments, so
|
|
1261
|
+
a feature cannot fold into the living docs with its reason unwritten. Its sibling
|
|
1262
|
+
`scaffold.placeholder` refuses the rest of an unedited `loam new` scaffold the same way — the exact
|
|
1263
|
+
template strings, a requirement or description nobody authored, must not become living truth.
|
|
1264
|
+
|
|
1265
|
+
### Where a capability lives (a declared name, and the document behind it)
|
|
1266
|
+
|
|
1267
|
+
A business capability — "payment splitting" — is spread across the living specs of every service
|
|
1268
|
+
that carries part of it. What was rejected here, and stays rejected, is an UNCHECKED free-text
|
|
1269
|
+
label: a hand-written `capability:` field has no ground truth, so it would end up on some features
|
|
1270
|
+
and not others, in three spellings of the same theme, never revisited — while creating the
|
|
1271
|
+
impression that an index exists.
|
|
1272
|
+
|
|
1273
|
+
A DECLARED name is different in exactly the way that mattered: it is checkable. A capability may be
|
|
1274
|
+
declared on either of two sides, and the vocabulary is their UNION.
|
|
1275
|
+
|
|
1276
|
+
**A name alone is a line in `architecture/capabilities.yaml`** — ids with an optional `description`
|
|
1277
|
+
and `owner`, nested ids such as `payments/refunds` kept as one flat key — and a requirement joins it
|
|
1278
|
+
with a `Capability:` list line in either spec file.
|
|
1279
|
+
|
|
1280
|
+
**A name with prose behind it gets a document: `capabilities/<cap>/spec.md`,** the authored business
|
|
1281
|
+
tree. The rule that separates the two is general and settles the question for every future
|
|
1282
|
+
vocabulary: **an entry with prose gets a file; an entry without prose stays a line in YAML.**
|
|
1283
|
+
(`architecture/permissions.yaml` is the principled exception — a `user/profile:read` pair has no
|
|
1284
|
+
prose, and a document per pair is ceremony.) A single YAML file grown to hold paragraphs is
|
|
1285
|
+
unworkable at fleet scale and is a second list nothing keeps current, which is the drift
|
|
1286
|
+
`loam init`'s removed `loam.docs.json` was removed for. So the DIRECTORY is the list: a directory is
|
|
1287
|
+
a capability if and only if it holds `spec.md`, nesting is spelled by the tree
|
|
1288
|
+
(`capabilities/payments/refunds/spec.md`), and a directory may be both a capability and the parent
|
|
1289
|
+
of others. `loam init` does not scaffold `capabilities/` — its existence is the opt-in, exactly as
|
|
1290
|
+
`architecture/adrs/`'s is.
|
|
1291
|
+
|
|
1292
|
+
The document carries narrative and then `## Requirements`, in the same grammar and read by the same
|
|
1293
|
+
parser as every other `spec.md` — this is where OpenSpec's `## Purpose` prose finally has a loam
|
|
1294
|
+
equivalent — with two rules of its own:
|
|
1295
|
+
|
|
1296
|
+
- **Every requirement needs a `Requirement-ID:`.** Optional in a service spec for OpenSpec
|
|
1297
|
+
compatibility, required here: these documents outlive every service that realizes them, so
|
|
1298
|
+
identity is the line and not the heading — otherwise rewording a heading is a removal and an
|
|
1299
|
+
addition, and every join made to it breaks silently. Missing is
|
|
1300
|
+
`capability.requirement-unidentified` (**error**).
|
|
1301
|
+
- **A capability requirement must be observable OUTSIDE the fleet.** `Operations:`, `Covers:`,
|
|
1302
|
+
`Publishes:` and `Consumes:` each resolve against one service's own contract or model, so a
|
|
1303
|
+
requirement carrying any of them is a service requirement filed at the wrong altitude:
|
|
1304
|
+
`capability.requirement-service-scoped` (**error**). `Requires:` is deliberately exempt — a
|
|
1305
|
+
permission is a domain fact, observable outside the fleet. The companion rule that a capability
|
|
1306
|
+
requirement **names no service** is an authoring rule that PR review holds and loam does not
|
|
1307
|
+
check: the only mechanical reading of it is scanning prose for declared service ids, which is a
|
|
1308
|
+
heuristic, and loam refuses-and-names rather than guessing.
|
|
1309
|
+
|
|
1310
|
+
A directory under `capabilities/` that holds neither the document nor a capability beneath it is
|
|
1311
|
+
`capability.doc-missing` (**warning**) — what `mkdir` leaves behind halfway through creating one. It
|
|
1312
|
+
declares nothing, so a `Capability:` line naming it still reports `capability.unknown`. And the
|
|
1313
|
+
axis's own two joins — `Capability:` and `Realizes:` — are refused inside a capability document
|
|
1314
|
+
(`capability.requirement-inert-join`, **error**): they point INTO the tree, so nothing reads them
|
|
1315
|
+
written in it, while both parse and read exactly like the working joins one directory over.
|
|
1316
|
+
|
|
1317
|
+
#### `Realizes:` — the join a service requirement makes into the tree
|
|
1318
|
+
|
|
1319
|
+
A service requirement says which promise it serves with a `Realizes:` line, entries spelled
|
|
1320
|
+
`<capability-id>#<Requirement-ID>`:
|
|
1321
|
+
|
|
1322
|
+
```markdown
|
|
1323
|
+
### Requirement: Authorize a payment
|
|
1324
|
+
Requirement-ID: PAY-AUTHORIZE
|
|
1325
|
+
The service SHALL reserve funds for an order before capture…
|
|
1326
|
+
|
|
1327
|
+
Operations: authorizePayment
|
|
1328
|
+
Realizes: checkout#CHECKOUT-CHARGE-ONCE
|
|
1329
|
+
```
|
|
238
1330
|
|
|
239
|
-
|
|
1331
|
+
This is **not** the same claim as `Capability:` beside it, and a requirement commonly carries both:
|
|
1332
|
+
`Capability: checkout` names a theme, `Realizes: checkout#CHECKOUT-CHARGE-ONCE` names one promise.
|
|
1333
|
+
Only the second can be graded in both directions, which is what makes the business tree checkable
|
|
1334
|
+
rather than merely present. It is written by whoever implements the requirement, never by the
|
|
1335
|
+
analyst — a business document must not be edited every time the fleet rearranges which service
|
|
1336
|
+
carries which part — and the API hop needs no line of its own, because a capability requirement
|
|
1337
|
+
reaches its operations by composing this join with the `Operations:` lines that already exist.
|
|
1338
|
+
|
|
1339
|
+
Two details of the grammar are load-bearing. The capability half is what makes the target
|
|
1340
|
+
addressable at all, since a `Requirement-ID` is unique only inside its own document. And the
|
|
1341
|
+
separator is the **last** `#`, not the first: the requirement half's grammar excludes `#` while a
|
|
1342
|
+
capability id is a YAML key and a directory name and is constrained nowhere, so splitting at the
|
|
1343
|
+
last one is unambiguous for every id there is.
|
|
1344
|
+
|
|
1345
|
+
- `capability.realizes-unknown` (**error**) — the entry names no capability requirement. Five
|
|
1346
|
+
distinct failures with five different fixes, and the message says which: the entry is not spelled
|
|
1347
|
+
`<capability-id>#<Requirement-ID>`; the capability is undeclared (close names offered); it is
|
|
1348
|
+
declared but has no document; the document declares no requirements yet; or it declares
|
|
1349
|
+
requirements but not this id (close names again). In a feature delta it gates `loam archive`
|
|
1350
|
+
(`--approve` overrides), for `capability.unknown`'s reason.
|
|
1351
|
+
- `capability.requirement-unrealized` (**warning**, fleet scope) — a capability requirement no
|
|
1352
|
+
living service requirement's `Realizes:` line names, one warning per requirement, subject
|
|
1353
|
+
`<capability>#<id>`. The sharper sibling of `capability.unrealized` and the one that survives a
|
|
1354
|
+
healthy-looking row: a capability with four requirements and three realized reports nothing at all
|
|
1355
|
+
through the other code. It never gates, because writing the business document ahead of the fleet
|
|
1356
|
+
is the intended use.
|
|
1357
|
+
|
|
1358
|
+
##### The pin: `@<digest>`, and the one thing it is for
|
|
1359
|
+
|
|
1360
|
+
An entry may carry the digest of the capability requirement it was written against:
|
|
1361
|
+
|
|
1362
|
+
```markdown
|
|
1363
|
+
Realizes: checkout#CHECKOUT-CHARGE-ONCE@9f2c1a4b6d0e5713
|
|
1364
|
+
```
|
|
240
1365
|
|
|
241
|
-
|
|
1366
|
+
**Nobody types this.** `loam rebase --living` writes it and rewrites it; an author writes the bare
|
|
1367
|
+
entry and is never asked for more. An entry without a pin is not a defect and never becomes one, so
|
|
1368
|
+
a corpus that has never been pinned grades exactly as it did before pins existed.
|
|
1369
|
+
|
|
1370
|
+
It exists because every other check on this axis is an EXISTENCE constraint.
|
|
1371
|
+
`capability.realizes-unknown` fires when the target is **gone** and is silent when the target has
|
|
1372
|
+
merely **changed** — so an analyst can narrow a promise and every requirement claiming to keep it
|
|
1373
|
+
goes on claiming so, in a corpus loam rewrote itself, with nothing anywhere recording that the claim
|
|
1374
|
+
was made about different words. The pin is what makes that visible:
|
|
1375
|
+
|
|
1376
|
+
- `capability.realizes-stale` (**warning**) — a pinned entry whose target has been rewritten since
|
|
1377
|
+
the pin was stamped. The join still resolves; the address is fine and the promise behind it moved.
|
|
1378
|
+
|
|
1379
|
+
It stays a **warning** and never gates. A moved promise has three legitimate outcomes — change the
|
|
1380
|
+
requirement, change the promise, or decide neither needed changing — and only the third is
|
|
1381
|
+
`loam rebase --living` on its own. An error would remove the middle one without `--approve`, and a
|
|
1382
|
+
gate would make an analyst's ordinary edit break other teams' builds.
|
|
1383
|
+
|
|
1384
|
+
Two grammar consequences are load-bearing. `@` is the separator rather than a second `#` because
|
|
1385
|
+
the entry is split at the *last* `#`, so a second one would move that boundary and re-parse every
|
|
1386
|
+
existing entry; `@` is excluded from `Requirement-ID`, which makes the split unambiguous rather than
|
|
1387
|
+
conventional. And a suffix that is not a well-formed digest is **not** a pin and not a second
|
|
1388
|
+
refusal — the whole entry stays the target, so `checkout#CHK-1@nonsense` fails under
|
|
1389
|
+
`capability.realizes-unknown` naming what the author actually typed.
|
|
1390
|
+
|
|
1391
|
+
Finally, a pin is invisible to `requirementDigest`. Pins and `Based-On:` lines are both bookkeeping
|
|
1392
|
+
a requirement carries about its own joins, and a digest that moved when bookkeeping moved would make
|
|
1393
|
+
every pin self-invalidating: one `rebase --living` would go on to invalidate every `Based-On:`
|
|
1394
|
+
baseline in the fleet — the corpus-wide cascade the pin exists to *report* rather than to cause.
|
|
1395
|
+
|
|
1396
|
+
Realizing a REQUIREMENT realizes its capability: a requirement carrying only `Realizes:` counts
|
|
1397
|
+
toward `capability.unrealized`, and a requirement carrying both joins is counted once.
|
|
1398
|
+
`loam list capabilities --json` carries the whole join — each capability's `requirements[]`, and
|
|
1399
|
+
what realizes each one (the key is absent for a capability with no document, and an empty array for
|
|
1400
|
+
a document declaring none).
|
|
1401
|
+
|
|
1402
|
+
#### `#req-` — the join a USE CASE makes, and the only one that crosses services
|
|
1403
|
+
|
|
1404
|
+
`Realizes:` is not the only way to keep a promise, and for a criterion that crosses services it is
|
|
1405
|
+
not enough. "I enter a login and a password and I am in" belongs to no single service's `spec.md`,
|
|
1406
|
+
because each promises only its own part — one announces the order, another sends the message, and
|
|
1407
|
+
neither can promise what happens between them. A use case can, because it IS the hop sequence. So a
|
|
1408
|
+
`dynamic view` already tagged `#cap-<slug>` may carry `#req-<slug>` as well:
|
|
242
1409
|
|
|
243
|
-
|
|
1410
|
+
```
|
|
1411
|
+
dynamic view uc_order_notification {
|
|
1412
|
+
#cap-order-notifications
|
|
1413
|
+
#req-NOTIFY-ONCE
|
|
1414
|
+
title 'Order notification'
|
|
1415
|
+
…
|
|
1416
|
+
}
|
|
1417
|
+
```
|
|
244
1418
|
|
|
245
|
-
|
|
1419
|
+
The second tag is **scoped by the first**: a `Requirement-ID` is unique only inside its own
|
|
1420
|
+
document, so the view must first say which capability it is about. Several `#req-` tags on one view
|
|
1421
|
+
are legal and normal — a flow commonly keeps two of a capability's promises. Both tags spell their
|
|
1422
|
+
id with every character outside `[A-Za-z0-9_-]` flattened to `-`, because that is all a LikeC4 tag
|
|
1423
|
+
name accepts (measured at the 1.59.2 pin; a rejected character truncates the tag rather than
|
|
1424
|
+
refusing it, which is why the flattening is a whitelist and not a list of substitutions).
|
|
1425
|
+
|
|
1426
|
+
- `usecase.requirement-unresolved` (**error**) — six failures with six fixes, and the message says
|
|
1427
|
+
which: the view resolves no `#cap-` tag, it resolves two, the capability has no document, the
|
|
1428
|
+
document declares no requirements yet, it declares none flattening to this slug (close ids
|
|
1429
|
+
offered, each already spelled as the tag to write), or two of its ids flatten to one slug.
|
|
1430
|
+
|
|
1431
|
+
**Either reserved prefix opts a view in.** A view carrying `#req-` and no `#cap-` is an author who
|
|
1432
|
+
asked to be graded and forgot half the claim; it earns the `unscoped` message rather than silence.
|
|
1433
|
+
That opt-in is **one predicate**, and every reader takes it: `loam validate`, `loam diff`,
|
|
1434
|
+
`loam delta`, `loam status`, the context and explore packs and `loam list capabilities` all decide
|
|
1435
|
+
"is this a use case" the same way, so a flow one of them can see is a flow all of them can see. The
|
|
1436
|
+
byte scan that lets a fleet with no use cases skip the LikeC4 load looks for both prefixes for the
|
|
1437
|
+
same reason — a gate narrower than the predicate is silence, not a smaller answer.
|
|
1438
|
+
|
|
1439
|
+
Being SEEN is not the same as claiming something, and the difference is worth stating because it
|
|
1440
|
+
looks like a gap and is not. A view with no resolved `#cap-` tag claims no capability, so it seeds
|
|
1441
|
+
nothing in `loam explore --capability` and keeps no promise in `loam list capabilities`' `keptBy` —
|
|
1442
|
+
a `#req-` tag is scoped by a `#cap-` tag and means nothing without one. What the shared predicate
|
|
1443
|
+
buys those two is that the flow is read at all: the fleet opts in, the flows are loaded, and
|
|
1444
|
+
`useCases.unreadable` answers honestly instead of the scan reporting a fleet that draws none.
|
|
1445
|
+
|
|
1446
|
+
**A feature can bring a flow with it**: `features/<FEAT>/usecases/<name>.likec4` is a views-only
|
|
1447
|
+
document copied into `architecture/usecases/` by `loam archive` and removed again by
|
|
1448
|
+
`loam unarchive`, so the flow that answers a cross-service promise ships in the same change as the
|
|
1449
|
+
promise. The route is **create-only**, for the glossary's reason exactly: a flow is one ordered hop
|
|
1450
|
+
sequence, the merge is a whole-file copy, and there is nothing to merge partially — a flow the
|
|
1451
|
+
living tree already holds is `usecase.flow-exists` (error, not `--approve`-overridable), and the fix
|
|
1452
|
+
is to edit `architecture/usecases/<name>.likec4` directly in the same pull request.
|
|
1453
|
+
|
|
1454
|
+
Its `#req-` tag resolves against **both corpora** — the living capability tree and this feature's own
|
|
1455
|
+
capability deltas — which is the rule `Realizes:` already follows, and without it the axis's headline
|
|
1456
|
+
case is refused by an error of its own making: an analyst adds `NOTIFY-ONCE`, the architect answers
|
|
1457
|
+
it with a flow, and the tag names a promise that is not living *yet*. The flow itself is graded
|
|
1458
|
+
against the map the same merge would leave behind, so a hop may name a service the feature's
|
|
1459
|
+
`delta.likec4` adds; a hop naming an element the merge does NOT land is `usecase.flow-invalid`
|
|
1460
|
+
(error, not `--approve`-overridable) and stops the archive before anything is written.
|
|
1461
|
+
|
|
1462
|
+
**Only a RESOLVED claim keeps a promise.** A broken `#req-` tag suppresses nothing, so
|
|
1463
|
+
`capability.requirement-unrealized` goes on firing beside the error — a typo that silenced it would
|
|
1464
|
+
turn a mistake into a green fleet. And a run that could not READ the flows (a single-target run, or
|
|
1465
|
+
an `architecture/` that did not parse) suspends `capability.requirement-unrealized` entirely: loam
|
|
1466
|
+
did not look, which is never the same answer as "there is nothing there".
|
|
1467
|
+
|
|
1468
|
+
**Both corpora are reported together, and neither is derived from the other.**
|
|
1469
|
+
`loam list capabilities --json` carries `keptBy` (the flows keeping a promise) beside `realizedBy`
|
|
1470
|
+
(the service requirements realizing it) on every `requirements[]` row, with the same three-state
|
|
1471
|
+
rule one level down — present and non-empty, present and empty, or **absent** when nobody looked —
|
|
1472
|
+
and an additive top-level `useCases` key saying which kind of absence it was.
|
|
1473
|
+
`capability.unrealized` counts flows as well: a capability whose criteria all cross services can be
|
|
1474
|
+
kept entirely by flows and named by no service requirement, and calling that "a word nobody adopted"
|
|
1475
|
+
would contradict loam's own answer in the same report. It stays asymmetric with
|
|
1476
|
+
`capability.requirement-unrealized`, which suspends when the flows are unreadable — a capability has
|
|
1477
|
+
independent evidence in its `Capability:`/`Realizes:` lines, while a cross-service promise often has
|
|
1478
|
+
no realizer but a flow.
|
|
1479
|
+
|
|
1480
|
+
The fleet's own files are the opt-in — either side of the union. A fleet holding neither
|
|
1481
|
+
`architecture/capabilities.yaml` nor `capabilities/` gets no capability findings at all, and once
|
|
1482
|
+
one exists the join is graded both ways:
|
|
246
1483
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
-
|
|
252
|
-
|
|
253
|
-
|
|
1484
|
+
```yaml
|
|
1485
|
+
capabilities:
|
|
1486
|
+
payments:
|
|
1487
|
+
description: take money for an order
|
|
1488
|
+
owner: payments-team
|
|
1489
|
+
payments/refunds: {}
|
|
1490
|
+
```
|
|
254
1491
|
|
|
255
|
-
|
|
1492
|
+
- `capability.unknown` (**error**) — a `Capability:` entry neither side declares, with close-name
|
|
1493
|
+
suggestions; in a feature delta it gates `loam archive` (`--approve` overrides). Silent when the
|
|
1494
|
+
fleet holds neither file, or when the YAML is invalid.
|
|
1495
|
+
- `capability.invalid` (**error**, fleet scope) — `capabilities.yaml` exists but does not read as a
|
|
1496
|
+
vocabulary; reported exactly once per `validate --all` run (single-target runs stay silent about
|
|
1497
|
+
the file). Every grade that RESOLVES against the vocabulary is suspended behind it, because
|
|
1498
|
+
grading a fleet against a file nobody can read is a cascade rather than a diagnosis — but the
|
|
1499
|
+
authored tree's own grades are not, since none of them consults the YAML.
|
|
1500
|
+
- `capability.unrealized` (**warning**, fleet scope) — a declared capability no living non-`REMOVED`
|
|
1501
|
+
requirement names, one warning per capability, naming the side that declared it: either a promise
|
|
1502
|
+
nobody implemented or a word nobody adopted.
|
|
1503
|
+
|
|
1504
|
+
The total is readable — `loam list capabilities` reports each capability's realizing requirements,
|
|
1505
|
+
services and draft/verified split, and `loam explore --capability <id>` seeds an exploration from
|
|
1506
|
+
the realizing services. `migrate-openspec` preserves capability identity through this same file:
|
|
1507
|
+
every routed requirement carries a `Capability:` line, and the staged target declares every living
|
|
1508
|
+
and active-horizon OpenSpec capability id (empty bodies — descriptions are not invented, the
|
|
1509
|
+
authored `## Purpose` prose stays verbatim under `legacy/`).
|
|
1510
|
+
|
|
1511
|
+
#### A feature can change a capability
|
|
1512
|
+
|
|
1513
|
+
`features/<FEAT>/capabilities/<cap>/spec.md` carries a delta against the living document, in the
|
|
1514
|
+
requirement grammar and delta algebra every service spec already uses —
|
|
1515
|
+
`## ADDED|MODIFIED|REMOVED Requirements`, `Requirement-ID:` identity, `Based-On:` pins — with
|
|
1516
|
+
nesting spelled by the tree exactly as the living corpus spells it
|
|
1517
|
+
(`features/FEAT-9/capabilities/payments/refunds/spec.md`). `loam archive` merges it through the same
|
|
1518
|
+
transactional path as every other document and **creates `capabilities/<cap>/spec.md`, and the
|
|
1519
|
+
`capabilities/` tree itself, when the feature is the first to name that capability**;
|
|
1520
|
+
`loam unarchive` takes it back and removes what the archive created; `loam rebase` pins its
|
|
1521
|
+
`MODIFIED`/`REMOVED` requirements.
|
|
1522
|
+
|
|
1523
|
+
**Both lifecycles coexist and neither is deprecated.** A capability document may still be edited
|
|
1524
|
+
directly under PR review, like `capabilities.yaml` and the use cases. What changes for a document a
|
|
1525
|
+
FEATURE touches is that a `MODIFIED` or `REMOVED` needs a pin: the first such feature in a fleet
|
|
1526
|
+
that has been editing by hand reports `delta.baseline-missing` (a warning that gates) and is fixed
|
|
1527
|
+
by `loam rebase <FEAT>`. What that buys is the protection the corpus had none of — two PRs rewriting
|
|
1528
|
+
one capability requirement were a git conflict at best and a silent overwrite at worst, and are now
|
|
1529
|
+
`delta.baseline-stale`.
|
|
1530
|
+
|
|
1531
|
+
**The document's three rules are graded on the DELTA too**, not only on the living file, and there
|
|
1532
|
+
they gate archive. Without that the feature-local delta becomes a second way to write service
|
|
1533
|
+
requirements: a capability requirement carrying `Operations:` would merge into the living tree and
|
|
1534
|
+
only then earn its error, against a document whoever reads the finding did not write.
|
|
1535
|
+
|
|
1536
|
+
**The archive refuses a promise nothing keeps, in both directions.**
|
|
1537
|
+
|
|
1538
|
+
- `capability.uncovered` (**warning that gates**, `--approve`-overridable) — the feature ADDS a
|
|
1539
|
+
capability requirement and nothing in the same feature keeps it: no `Realizes:` line in its own
|
|
1540
|
+
service deltas, and no `#req-` tag in its own `usecases/` flows. The severity is the judgement:
|
|
1541
|
+
the document is legal, the merge is what is unsafe, because once merged the only thing that will
|
|
1542
|
+
mention the promise again is a fleet-scope warning nobody reads. **Both carriers count, and only
|
|
1543
|
+
a resolved claim does** — the `#cap-` tag to exactly one declared capability and the `#req-` tag
|
|
1544
|
+
to exactly one of its requirements, resolved against the vocabulary and the requirement ids this
|
|
1545
|
+
feature's own merge would leave behind. A broken tag of either kind is an error in its own right
|
|
1546
|
+
and never also marks a promise kept; a typo that silenced this gate would turn a mistake into a
|
|
1547
|
+
green archive. A **living** flow does not count either — a use case already in `architecture/`
|
|
1548
|
+
cannot be keeping a promise this feature has not merged yet, and the flow that keeps it ships in
|
|
1549
|
+
the same change.
|
|
1550
|
+
- `usecase.flow-invalid` (**error**, never `--approve`-overridable) — the feature's `usecases/`
|
|
1551
|
+
could not be read against that post-merge map: a parse error, a hop naming an element the merge
|
|
1552
|
+
does not land, or a landscape merge that itself refuses. The flows were not graded, so the archive
|
|
1553
|
+
would copy them into `architecture/` for somebody else's `validate --all` to fail on. `--approve`
|
|
1554
|
+
overrides loam's judgement about coherence, never its ability to read an axis.
|
|
1555
|
+
- `usecase.flow-exists` (**error**, never `--approve`-overridable) — a flow this feature introduces
|
|
1556
|
+
that `architecture/usecases/` already holds. The merge is a whole-file copy, so it would replace
|
|
1557
|
+
an authored hop sequence wholesale; the same shape and the same severity as `glossary.term-exists`,
|
|
1558
|
+
and the fix is the same — edit the living document directly in this change, where git produces an
|
|
1559
|
+
ordinary conflict.
|
|
1560
|
+
- `capability.remove-requirement-realized` (**error**) — the same join in the removal direction.
|
|
1561
|
+
Unlike the addition it has no legal reading: after the merge a living service document nobody
|
|
1562
|
+
touched would carry a pointer at nothing. Graded against the POST-MERGE fleet, so the escape
|
|
1563
|
+
hatches fall out rather than being special-cased — retiring the realizing requirement in the same
|
|
1564
|
+
feature, or restating it without the line, is no breach; a `MODIFIED` that keeps the line still
|
|
1565
|
+
is.
|
|
1566
|
+
|
|
1567
|
+
Two `--json` notes. `loam rebase` gains the additive `capabilities` and `capabilityPins` keys
|
|
1568
|
+
(`pins[]`/`services[]` untouched), and `loam status` gains `capabilities` rows in `artifacts[]`. And
|
|
1569
|
+
`Issue.subject` on a `delta.*` code no longer always names a service — a capability delta files
|
|
1570
|
+
under the capability id, so a consumer matching `subject` against the service list finds no match
|
|
1571
|
+
rather than the wrong one.
|
|
1572
|
+
|
|
1573
|
+
`loam new <FEAT> --capability <cap>` scaffolds the delta, and `loam show` and `loam delta` project
|
|
1574
|
+
it. The context pack still says nothing about a capability delta — it reports the fleet's
|
|
1575
|
+
capabilities and the flows claiming them, not what a feature is changing about one. A capability
|
|
1576
|
+
document also remains editable directly under PR review, which is the same lifecycle
|
|
1577
|
+
`capabilities.yaml`, `permissions.yaml` and the living use cases already have; the feature route
|
|
1578
|
+
does not replace it. Two rules keep the corpus from becoming a second copy of the living specs and
|
|
1579
|
+
neither may be softened: a capability requirement must be observable outside the fleet (above), and **neither
|
|
1580
|
+
corpus is derived from the other** — `gherkin` and `verify` keep computing from service
|
|
1581
|
+
requirements, so a service repository can still validate itself with nothing but its own files.
|
|
1582
|
+
|
|
1583
|
+
For a theme that crosses services and matches no structural unit, a **LikeC4 tag** also remains
|
|
1584
|
+
available and checkable: tags are declared in a `specification` block, so a misspelling is a parse
|
|
1585
|
+
error rather than quiet drift.
|
|
1586
|
+
|
|
1587
|
+
### Deliberate limits of the workflow surface
|
|
1588
|
+
|
|
1589
|
+
loam keeps one fixed lifecycle because its cross-artifact checks need one versioned meaning of
|
|
1590
|
+
ready. Several useful product shapes remain outside that boundary on purpose:
|
|
1591
|
+
|
|
1592
|
+
- **Configurable workflow DAGs** — arbitrary project graphs offer more workflow freedom, but would
|
|
1593
|
+
make readiness project-specific. loam derives state from one fixed artifact graph instead.
|
|
1594
|
+
- **External planning roots** — loam keeps one docs root because that root also owns the system
|
|
1595
|
+
topology. Reconsider an external reference index if several independently governed docs roots
|
|
1596
|
+
become a common deployment shape.
|
|
1597
|
+
- **Worksets and editor sessions** — loam has no workspace manager. `loam delta <FEAT> --service
|
|
1598
|
+
<id>` derives a model-scoped work view without owning folders, windows or editor state.
|
|
1599
|
+
- **Configurable lifecycle profiles** — project behavior does not change with the agent or user.
|
|
1600
|
+
`init --agent-profile` narrows only which shared workflow entry points are installed; it does not
|
|
1601
|
+
fork the artifact graph, gates or command semantics. The incident-reporting support entry stays
|
|
1602
|
+
installed in every profile because it is not part of that graph.
|
|
1603
|
+
- **TUI** — agents and CI get `--json`; humans get the files and the forge. A third surface would be
|
|
1604
|
+
a third thing to keep truthful.
|
|
1605
|
+
- **Authored `tasks.md` as authoritative state** — loam derives its actionable checklist (`loam
|
|
1606
|
+
delta`, `loam verify`), so it cannot drift from the delta. Imported task lists are preserved as
|
|
1607
|
+
non-authoritative migration evidence until a human chooses their disposition.
|
|
1608
|
+
- **Per-tool workflow forks** — `AGENTS.md` is the tool-agnostic contract. Generated adapters wrap
|
|
1609
|
+
shared bodies and are never independent process definitions.
|
|
1610
|
+
|
|
1611
|
+
Separately, the compatibility stance is one-way: **loam reads OpenSpec, never writes it.**
|
|
1612
|
+
Serializing drops `## Purpose` and the `## Requirements` wrapper, both of which OpenSpec's parser
|
|
1613
|
+
requires — so loam output is not OpenSpec input, and pretending otherwise would corrupt someone's
|
|
1614
|
+
repo politely. [MIGRATING-from-OpenSpec.md](MIGRATING-from-OpenSpec.md) has the full account, with
|
|
1615
|
+
the evidence pinned in the
|
|
1616
|
+
[compatibility test](https://github.com/ybotok/loam/blob/main/test/openspec-compat.test.ts).
|
|
256
1617
|
|
|
257
1618
|
### How archive writes, and how unarchive undoes it
|
|
258
1619
|
|
|
259
|
-
The merge is computed in full before anything is written, and then committed file by file: each new
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
**
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
1620
|
+
The merge is computed in full before anything is written, and then committed file by file: each new
|
|
1621
|
+
version is staged as a temp file **in the target's own directory** and renamed into place, so a
|
|
1622
|
+
reader sees either the old bytes or the new ones and never a half-written document. If any file
|
|
1623
|
+
fails — including the final move into `features/archive/` — the files already swapped are put back
|
|
1624
|
+
from the bytes read before the swap, and the command says so.
|
|
1625
|
+
|
|
1626
|
+
**The whole path moves bytes, not strings.** Content is encoded once when a write is staged, the
|
|
1627
|
+
compare-and-set before each rename compares bytes, and the snapshot pre-image is written unmodified;
|
|
1628
|
+
text is produced only where a parser needs it. A file loam would have to parse as text and cannot
|
|
1629
|
+
decode as UTF-8 refuses the merge (`merge-failed`, naming the file) rather than being written back
|
|
1630
|
+
with every undecodable byte replaced by U+FFFD — which is what used to happen, to the living
|
|
1631
|
+
document *and* to the snapshot meant to undo it, leaving nothing to restore from. PowerShell's `>`
|
|
1632
|
+
defaulting to UTF-16 is a realistic way to reach this.
|
|
1633
|
+
|
|
1634
|
+
**A process killed between two renames is recoverable.** Before the first swap,
|
|
1635
|
+
`archive`/`unarchive` write `.loam-commit` at the docs-repo root — an intent record naming the
|
|
1636
|
+
command, the feature, the host and pid, the final move, and a before/after digest per path — and
|
|
1637
|
+
fsync it (and its directory where the platform allows). It is deliberately not a source of truth and
|
|
1638
|
+
never disagrees with the files: every judgement it supports is made by re-reading the living bytes
|
|
1639
|
+
and comparing digests, and every byte a repair writes comes from the snapshot and is verified
|
|
1640
|
+
against the digest captured before the crash. The next `archive`/`unarchive` recovers from it first,
|
|
1641
|
+
under the lock: `completed` when the final move landed, `consistent` when no file was left in the
|
|
1642
|
+
other state, `repaired` when files were put back, and `commit-interrupted` (a refusal, exit 1) when
|
|
1643
|
+
a half-written file now holds bytes that are neither state or the repairing pre-image is gone or
|
|
1644
|
+
altered. An archive is **undone**; an unarchive is **finished**, because the merged text it was
|
|
1645
|
+
replacing is written down nowhere else. The record is removed after the final rename and on every
|
|
1646
|
+
rollback path, so outside the commit window it does not exist. `.loam-commit` and `.loam-lock` are
|
|
1647
|
+
both transient writer state and both belong in a docs-repo `.gitignore`; `loam doctor` grades them
|
|
1648
|
+
(`doctor.commit-interrupted`, `doctor.commit-unreadable`, `doctor.docs-locked`,
|
|
1649
|
+
`doctor.staging-temps`) instead of reporting a half-merged repo as healthy.
|
|
1650
|
+
|
|
1651
|
+
**One writer at a time, and only over the bytes it read.** `archive` and `unarchive` hold an
|
|
1652
|
+
advisory lock on the docs repo (`.loam-lock`, created `O_EXCL`, released in a `finally`) across the
|
|
1653
|
+
whole plan-and-commit; a second one refuses with `docs-busy` having read and written nothing, and
|
|
1654
|
+
re-running once the first finishes works. A lock naming a process that is no longer alive on this
|
|
1655
|
+
host is broken once and retried, so a crash cannot wedge the repo. Inside that lock the swap is
|
|
1656
|
+
still guarded per file: each target is re-read immediately before its rename and compared with the
|
|
1657
|
+
pre-image the plan was computed from, and a mismatch fails the commit (`merge-failed`) rather than
|
|
1658
|
+
overwriting an edit that landed in between. Rollback is guarded the same way — a file whose current
|
|
1659
|
+
bytes are not the ones the merge wrote is not restored over, and is named in the failures instead,
|
|
1660
|
+
which correctly promotes the answer to `rollback-incomplete`. Cleanup after a failed create prunes
|
|
1661
|
+
only the directories it created and only while they are empty (`rmdir` up to the first `ENOTEMPTY`),
|
|
1662
|
+
never a recursive delete: the directory that would have been removed is a directory somebody else
|
|
1663
|
+
may have just put a file into.
|
|
1664
|
+
|
|
1665
|
+
Undoing that merge is not a matter of inverting it. **A `MODIFIED` requirement's previous text is
|
|
1666
|
+
recorded nowhere** — the delta says what the requirement became, never what it was — and the
|
|
1667
|
+
landscape merge drops the feature tags, so the lines it added stop being identifiable the moment
|
|
1668
|
+
they land. Anything reconstructed would be a plausible guess at the old docs, which is the kind of
|
|
1669
|
+
quiet fiction the rest of loam exists to prevent.
|
|
1670
|
+
|
|
1671
|
+
So archive writes the bytes down. Before it swaps anything it copies every file it is about to
|
|
1672
|
+
overwrite into `<feature>/.loam-before/`, with a `manifest.json` naming each one, whether it existed
|
|
1673
|
+
at all (a file the merge *created* is restored by deleting it), and **two** hashes: `after`, of what
|
|
1674
|
+
the merge wrote, and `before`, of the pre-image sitting beside the manifest (null when archive
|
|
1675
|
+
created the file). That directory travels with the feature into `features/archive/`, and
|
|
1676
|
+
`loam unarchive` puts it back.
|
|
1677
|
+
|
|
1678
|
+
The manifest is **version 3**. Version 2 added `before`: a version-1 snapshot described only what
|
|
1679
|
+
archive *wrote*; nothing described the bytes it would *restore*, so a pre-image altered inside the
|
|
1680
|
+
archived feature directory — a bad merge, a rebase, a stray editor — was written back verbatim and
|
|
1681
|
+
every loam surface certified the result as "the living docs are back to what they said before the
|
|
1682
|
+
archive". `unarchive` now hashes every pre-image before staging anything and refuses on a mismatch
|
|
1683
|
+
(`snapshot-corrupt`), and a version-1 snapshot is refused as `snapshot-missing` with the answer
|
|
1684
|
+
being version control. Version 3 re-keys every entry under `services/` by **`(service, artifact)`**
|
|
1685
|
+
— the service id (the leaf directory name, the one thing a subsystem re-cut never changes) and the
|
|
1686
|
+
artifact's path inside the service directory — beside the as-archived literal `path`, which keeps
|
|
1687
|
+
naming the pre-image under `.loam-before/files/`. At restore time the write target resolves through
|
|
1688
|
+
the **current services enumeration**: the bytes land wherever the service lives *today*, not
|
|
1689
|
+
wherever it lived on the day of the archive, and an id the enumeration no longer answers falls back
|
|
1690
|
+
to the literal path (an absent service restores as a create, exactly as before). Entries under
|
|
1691
|
+
`architecture/` and `features/` stay literal-path-only — they do not move. Version-2 manifests are
|
|
1692
|
+
still read, forever, under their own literal-path rules: they already sit inside archived features,
|
|
1693
|
+
which carry them across loam upgrades. Correspondingly, `archive` refuses to replace a
|
|
1694
|
+
`.loam-before/` whose pre-images no longer match the living bytes — version 2 or 3 alike, with a
|
|
1695
|
+
version-3 claim graded at its service's current directory: the previous archive's writes are still
|
|
1696
|
+
in the docs and those files are the only way back.
|
|
1697
|
+
|
|
1698
|
+
Some checks run at plan time rather than in the gate, because only the computed merge can see them.
|
|
1699
|
+
A **living** requirement sitting outside `## Requirements` refuses the whole archive
|
|
1700
|
+
(`living.requirement-outside-requirements`, error): the merge rewrites only that section, so the
|
|
1701
|
+
requirement would land in the file twice — and `--approve` does not override it, because the
|
|
1702
|
+
duplication is mechanical, not a judgment call; re-home the requirement first. A delta that
|
|
1703
|
+
redefines a path+method the living OpenAPI already has (`openapi.op-modified`, warning) merges
|
|
1704
|
+
anyway, but the plan says so — the overwrite is wholesale, and the living definition it replaces
|
|
1705
|
+
deserved an eye. An unparseable `delta.likec4` fails the archive at plan time regardless of
|
|
1706
|
+
`--approve`: the alternative was archiving with the architecture axis silently dropped, which is the
|
|
1707
|
+
data loss this tool exists to prevent.
|
|
1708
|
+
|
|
1709
|
+
The landscape merge **splices authored source, it does not re-serialize**. Each new element and
|
|
1710
|
+
relationship is copied into the living landscape byte-for-byte from `delta.likec4` — technology,
|
|
1711
|
+
style, icons, links, metadata, nested children, every tag — with exactly one edit: the feature's own
|
|
1712
|
+
tag is stripped (the additions are baseline now — the drop above is why unarchive restores bytes),
|
|
1713
|
+
and a construct the strip empties goes with it (`x = kind 'y' { #FEAT-1 }` lands as `x = kind 'y'`).
|
|
1714
|
+
Placement is deterministic and **service-grouped**, and never moves an authored byte — it only
|
|
1715
|
+
chooses where the new blocks go. A top-level element whose service the model already draws (the
|
|
1716
|
+
`metadata { service }` binding first, the title as fallback) lands immediately after the last
|
|
1717
|
+
element resolving to that service — it joins its service's neighborhood; an element opening a new
|
|
1718
|
+
service lands in the trailing region before the model's closing brace, sorted among its fellow
|
|
1719
|
+
no-neighborhood insertions. A relationship lands after the last top-level relationship touching its
|
|
1720
|
+
source's service (either endpoint counts), ordered by a stable key past a shared anchor, with the
|
|
1721
|
+
same sorted trailing region as the fallback. The unit of that sort is the **neighborhood, not the
|
|
1722
|
+
statement**: a contiguous same-service run of elements — or the cluster of relationships grown
|
|
1723
|
+
around the statements touching one service — moves through the trailing walks as a block, keyed by
|
|
1724
|
+
its leading statement (an anchored join sits where its anchor is, wherever its own id or key would
|
|
1725
|
+
sort, so comparing per statement would make placement depend on which archive ran first). That is
|
|
1726
|
+
what makes the scheme **order-independent**: archiving feature A then feature B (touching different
|
|
1727
|
+
services) yields the byte-identical landscape to B then A, so concurrent archive PRs usually splice
|
|
1728
|
+
different lines and git merges them without help. The insertions carry no marker comment — which
|
|
1729
|
+
archive added what is the git history's to say. A **nested** element lands inside its parent's block
|
|
1730
|
+
— inside the living parent when the landscape already has it, riding verbatim inside the spliced
|
|
1731
|
+
parent when the parent is new — never as a flat dotted id, which LikeC4 rejects at top level. LikeC4
|
|
1732
|
+
reference resolution is not order-dependent, so a relationship placed before an element it names
|
|
1733
|
+
still parses — and because splicing is text surgery, the computed landscape is **parsed in memory
|
|
1734
|
+
before anything is written**: a result LikeC4 rejects — a splice bug, or legal inputs the living
|
|
1735
|
+
document cannot absorb, like an element kind or tag its `specification` block never declares —
|
|
1736
|
+
refuses the archive at plan time (`merge-failed`, nothing written), the same discipline as the
|
|
1737
|
+
unparseable delta. A living declaration sharing its line with another statement (legal LikeC4 the
|
|
1738
|
+
splice map cannot see around — its statement scan runs to the end of the line) refuses the same way:
|
|
1739
|
+
give each top-level declaration its own line, then re-run.
|
|
1740
|
+
|
|
1741
|
+
The OpenAPI merge carries components along with operations, and since the path-item/component
|
|
1742
|
+
baselines it decides each one **by verdict rather than by reachability**: a component the feature
|
|
1743
|
+
declares with a pin equal to its own content is a quote and is skipped (the living document keeps
|
|
1744
|
+
whatever landed since); one whose pin matches the living value is an edit and is copied; a stale one
|
|
1745
|
+
reaches the merge only under `--approve` and is copied with the plan saying so; an unpinned one
|
|
1746
|
+
identical to living is left alone, and an unpinned one that differs is copied
|
|
1747
|
+
(`openapi.component-modified`, warning) — including a differing component nothing in the feature
|
|
1748
|
+
references, which the old reachability closure silently dropped. A genuinely **new** component is
|
|
1749
|
+
copied only when reachable from content actually written — a fixpoint over the copies' own `$ref`s,
|
|
1750
|
+
which is what keeps cycles terminating and unrelated namespaces uncopied. A `$ref` reachable from
|
|
1751
|
+
the written content and copied components that resolves in **neither** document blocks the archive
|
|
1752
|
+
(`openapi.ref-unresolved`, error): merging it would write a dangling reference. `--approve`
|
|
1753
|
+
overrides it — unlike the mechanical refusals, this one is a judgment call. External references —
|
|
1754
|
+
URLs, file paths, anything not starting `#/` — are out of scope: left untouched, never gated.
|
|
1755
|
+
|
|
1756
|
+
`loam archive --json` speaks the same machine contract as everything else: on success (or
|
|
1757
|
+
`--dry-run`) the plan (`{path, action, to?}` per file), the non-blocking warnings, and whatever
|
|
1758
|
+
`--approve` overrode; on refusal a stable `error.code` — `not-coherent` and
|
|
1759
|
+
`living-outside-requirements` carry the issues, `archive-exists` names a taken destination,
|
|
1760
|
+
`docs-busy` means another archive or unarchive holds the lock and nothing was touched,
|
|
1761
|
+
`merge-failed` means nothing was written (or everything was rolled back), `rollback-incomplete`
|
|
1762
|
+
lists the files that need a hand.
|
|
280
1763
|
|
|
281
1764
|
`unarchive` refuses rather than guesses, each refusal under its own `--json` `error.code`:
|
|
282
1765
|
|
|
283
|
-
- `feature-active` — a feature of that id is active again; restoring over it would bury work in
|
|
284
|
-
|
|
285
|
-
- `snapshot-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
1766
|
+
- `feature-active` — a feature of that id is active again; restoring over it would bury work in
|
|
1767
|
+
flight;
|
|
1768
|
+
- `snapshot-missing` — archived before snapshots existed, or by an older layout version (a version-1
|
|
1769
|
+
manifest, which records nothing about its restore source); the living docs have to come back from
|
|
1770
|
+
version control instead;
|
|
1771
|
+
- `snapshot-stale` — a merged file changed after the archive, so this would be a revert of someone
|
|
1772
|
+
else's work rather than an undo. `--force` says that was meant. The test is "the file holds
|
|
1773
|
+
neither what archive wrote nor what this restore would write", so a file already holding its own
|
|
1774
|
+
pre-image — what an interrupted restore leaves behind — is not mistaken for a later change;
|
|
1775
|
+
- `snapshot-corrupt` — a pre-image no longer hashes to the `before` digest archive recorded for it,
|
|
1776
|
+
so restoring it would write text nobody authored. **`--force` does not override this**, and the
|
|
1777
|
+
difference is the point: `--force` discards *later* changes to the living docs, while the damage
|
|
1778
|
+
here is to the undo itself;
|
|
1779
|
+
- `commit-interrupted` — a previous `archive`/`unarchive` was killed mid-commit and this run cannot
|
|
1780
|
+
repair it on its own.
|
|
1781
|
+
|
|
1782
|
+
Rules (`loam validate`): every requirement has ≥1 scenario; every C4 edge `op` resolves to an
|
|
1783
|
+
OpenAPI operation governed by a requirement; and **the diff applies** — a `MODIFIED`/`REMOVED`
|
|
1784
|
+
requirement exists in the living spec, an `ADDED` one does not, and a section heading matches
|
|
1785
|
+
`## ADDED|MODIFIED|REMOVED Requirements` exactly. A near-miss heading (`## ADDED Requirement`,
|
|
1786
|
+
singular) parses as prose, so without this check archive merges nothing and reports nothing.
|
|
1787
|
+
`## RENAMED Requirements` — OpenSpec's fourth delta operation — gets the same error
|
|
1788
|
+
(`delta.unknown-section`): loam's rename is a stable `Requirement-ID` carried through a `MODIFIED`
|
|
1789
|
+
requirement (legacy documents can still use `REMOVED` + `ADDED`). And an `ADDED` legacy name that
|
|
1790
|
+
differs from a living requirement's only in case is a warning (`delta.added-near-duplicate`):
|
|
1791
|
+
without a stable ID, both spellings would coexist in the living spec.
|
|
1792
|
+
|
|
1793
|
+
**The section heading is what gives a requirement its kind.** A requirement under any other H2 (`##
|
|
1794
|
+
Behavior`, `## Error Handling` — a historically accepted legacy OpenSpec "complete future state"
|
|
1795
|
+
shape) has no kind, and archive merges nothing for it; `delta.requirement-not-merged` names each one
|
|
1796
|
+
and the heading that stranded it — a warning for adopted history that **gates archive** (authored
|
|
1797
|
+
content must not vanish silently; `--approve` overrides). OpenSpec v1.7's default spec-driven schema
|
|
1798
|
+
requires a real delta unless `.openspec.yaml` explicitly uses `skip_specs: true`; migration applies
|
|
1799
|
+
that zero-spec opt-out only from a valid marker whose named built-in or direct project schema
|
|
1800
|
+
resolves, never merely because a custom artifact graph omits specs. Legacy archive presence is not
|
|
1801
|
+
proof that the shape is currently valid. When *no* requirement in the file sits under a delta
|
|
1802
|
+
section — prose headings only, requirements above every heading, or a file that only quotes the
|
|
1803
|
+
living state — the grade goes up to an **error**, `delta.no-delta-sections`: a delta that would
|
|
1804
|
+
merge nothing as a whole is not a valid delta. `## Requirements` stays exempt from the
|
|
1805
|
+
per-requirement warning: quoting the living state inside a delta is legal and merges nothing by
|
|
1806
|
+
design. The heading must be reachable, too — a leading UTF-8 BOM used to hide
|
|
1807
|
+
`## MODIFIED Requirements` on line 1 and void the entire delta, so the parser strips one.
|
|
292
1808
|
|
|
293
1809
|
## The verification record
|
|
294
1810
|
|
|
295
|
-
`features/<FEAT>/verification.yaml` is the done-check written down. `loam verify <FEAT>` derives a
|
|
1811
|
+
`features/<FEAT>/verification.yaml` is the done-check written down. `loam verify <FEAT>` derives a
|
|
1812
|
+
**checklist** from the feature's own artifacts — the same files `validate` reads, never the code:
|
|
296
1813
|
|
|
297
1814
|
| kind | one claim per | reads |
|
|
298
1815
|
|---|---|---|
|
|
299
1816
|
| `service.exists` | tagged top-level element the delta introduces | `delta.likec4` |
|
|
300
1817
|
| `api.exposes` | operationId the feature's openapi delta adds that the living one lacks | `specs/<svc>/openapi.yaml` |
|
|
1818
|
+
| `event.declares` | (direction, message) the feature's asyncapi delta declares that the living contract's send/receive sets lack — quotes and removals are never questions | `specs/<svc>/asyncapi.yaml` |
|
|
301
1819
|
| `c4.calls` | tagged edge carrying `metadata { op }` | `delta.likec4` |
|
|
302
1820
|
| `scenario.tested` | scenario of every ADDED/MODIFIED requirement | `specs/<svc>/spec.md` + `specs/<svc>/arch.spec.md` |
|
|
303
1821
|
|
|
304
|
-
Each claim's **id** is `<kind>-<8 hex>`, hashed from the feature id and what the claim says (with an
|
|
1822
|
+
Each claim's **id** is `<kind>-<8 hex>`, hashed from the feature id and what the claim says (with an
|
|
1823
|
+
occurrence counter for genuine duplicates). For `scenario.tested` "what the claim says" includes the
|
|
1824
|
+
requirement's stable `Requirement-ID` when present (otherwise its legacy heading), plus a hash of
|
|
1825
|
+
the scenario's **body**, not just its title. So a heading-only rename under one stable ID preserves
|
|
1826
|
+
its evidence, while the same feature otherwise yields the same ids on every run — two runs are
|
|
1827
|
+
diffable — reordering the artifacts renames nothing, and **rewording a scenario renames its claim,
|
|
1828
|
+
even a Given/When/Then rewrite under an unchanged title**, which is the point: an answer about text
|
|
1829
|
+
nobody wrote must not carry over as if it still applied.
|
|
1830
|
+
|
|
1831
|
+
`loam verify <FEAT> --service <id> --record <answers.json>` takes the answers back and refuses
|
|
1832
|
+
anything that does not answer the *current* checklist, each under its own `--json` `error.code`:
|
|
1833
|
+
`answers-unreadable` (not JSON, or a verdict outside `confirmed` / `unconfirmed`),
|
|
1834
|
+
`answers-mismatch` (an id nobody asked about, a claim with no answer, or one answered twice),
|
|
1835
|
+
`answers-unevidenced` (a `confirmed` with no `file:line` behind it). An unchecked claim must never
|
|
1836
|
+
be able to masquerade as checked.
|
|
1837
|
+
|
|
1838
|
+
**`--results <report.json>` answers the `scenario.tested` claims mechanically, from a cucumber JSON
|
|
1839
|
+
test report** — the format `cucumber-js --format json` emits, and cucumber-jvm, behave and SpecFlow
|
|
1840
|
+
speak too. The contract is exactly this and nothing more: a top-level array of features, each
|
|
1841
|
+
`elements[]` (scenarios) with `name`, `tags[] {name}` and `steps[] {result {status}}` — plus the two
|
|
1842
|
+
places those dialects put a failure the steps never see: cucumber-jvm's per-element
|
|
1843
|
+
`before[]`/`after[]` hook results and behave's element-level `status`. Every other field is ignored,
|
|
1844
|
+
tagless elements (backgrounds, hand-written scenarios) are invisible, and a file that is not that
|
|
1845
|
+
array at all refuses under `answers-unreadable` rather than quietly answering every claim "not
|
|
1846
|
+
found".
|
|
1847
|
+
|
|
1848
|
+
Matching is by digest and nothing else: a claim is answered by the report scenarios carrying its
|
|
1849
|
+
`@loam-digest-<16hex>` tag — the tag `loam gherkin` stamped, the same 16 hex of the same body hash
|
|
1850
|
+
the claim id folds in; the axis rides in that hash (the arch recipe salts the body with its file
|
|
1851
|
+
name), so a business run can never answer an arch claim however identically the two scenarios are
|
|
1852
|
+
worded. Names never match anything: a reworded spec scenario matches nothing until the suite is
|
|
1853
|
+
regenerated and re-run — an agent must not be able to SAY a scenario is tested; only a green run
|
|
1854
|
+
may. The verdicts: every matching occurrence (a digest the report holds twice is a re-run, and all
|
|
1855
|
+
occurrences count) ran at least one step, every step `passed`, every before/after hook `passed`, and
|
|
1856
|
+
the element-level status (when the dialect carries one) is `passed` → `confirmed`, with evidence
|
|
1857
|
+
`<report-path>: <feature uri or name> › <scenario name>` per occurrence; a failed / undefined /
|
|
1858
|
+
pending / ambiguous step (`failed at step N`), a failing hook (`failed in a before/after hook`), a
|
|
1859
|
+
failed element status, a skipped-only run (`skipped`), or no match at all (`not found in report`) →
|
|
1860
|
+
`unconfirmed` with the reason as the note.
|
|
1861
|
+
|
|
1862
|
+
`--results` OWNS every `scenario.tested` claim **in the same run**: an answers-file entry for one is
|
|
1863
|
+
refused (`answers-mismatch` — the runner owns it), `--record` alongside must answer exactly the
|
|
1864
|
+
non-scenario claims, and `--results` alone is legal only when the checklist is all scenarios
|
|
1865
|
+
(otherwise the refusal lists the unanswered ids). On an archived feature `--results` refuses exactly
|
|
1866
|
+
as `--record` does — frozen history.
|
|
1867
|
+
|
|
1868
|
+
The record also writes down **which report it read**: a `report:` block with the file's path, the
|
|
1869
|
+
sha256 of its bytes, its mtime and the count of digest-tagged scenarios — on the record for the
|
|
1870
|
+
all-at-once form, and filed with that repository's attestation in the federated form, so it is
|
|
1871
|
+
pruned with its answers. In federated mode the report must resolve *inside* the attesting
|
|
1872
|
+
repository, the same rule evidence paths obey, and a report that IS committed there and differs from
|
|
1873
|
+
the attested commit is refused by name. Be precise about what that proves: it identifies which file
|
|
1874
|
+
was consumed and, when the file is committed, that it matches the attested commit. It does not prove
|
|
1875
|
+
the file came from executing that commit. No digest can.
|
|
1876
|
+
|
|
1877
|
+
#### Any runner can answer, through loam's own shape
|
|
1878
|
+
|
|
1879
|
+
Cucumber's JSON is not the contract; the **digest** is. `--results` therefore also accepts loam's
|
|
1880
|
+
own runner-neutral shape, chosen by its marker key:
|
|
305
1881
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
1882
|
+
```json
|
|
1883
|
+
{
|
|
1884
|
+
"loamScenarioReport": 1,
|
|
1885
|
+
"results": [
|
|
1886
|
+
{ "digest": "9f2c1a4b6d0e5713", "status": "passed", "test": "RefundPaymentTest#capturedOrder" }
|
|
1887
|
+
]
|
|
1888
|
+
}
|
|
1889
|
+
```
|
|
311
1890
|
|
|
312
|
-
|
|
1891
|
+
`digest` is the 16 lowercase hex of the `@loam-digest-…` tag `loam gherkin` stamped on the scenario.
|
|
1892
|
+
`status` is `passed` or `failed` — nothing else. `test` is optional free text: the runner's own name
|
|
1893
|
+
for the run, used as the evidence string a reader follows. `loamScenarioReport: 1` is the format
|
|
1894
|
+
version this loam reads; any other value refuses rather than being partly understood.
|
|
1895
|
+
|
|
1896
|
+
This exists so a fleet on JUnit, pytest, Playwright, Vitest or a house runner can reach `verified` at
|
|
1897
|
+
all. Before it, a team whose evidence was a real green run was held at `attested` **by a file
|
|
1898
|
+
format** — which is the one thing the attested/verified distinction was never meant to mean. An
|
|
1899
|
+
answer from this shape is `answered_by: runner`, identical to a cucumber one, because it is the same
|
|
1900
|
+
claim answered to the same standard by the same identity: a content-derived digest, and a status
|
|
1901
|
+
saying a run reported it green. **Adapting a runner's output into this shape is a build step you
|
|
1902
|
+
own**; loam parses no vendor format it has not documented.
|
|
1903
|
+
|
|
1904
|
+
It adds no forgeability. loam cannot prove any JSON came from executing a commit — that is why the
|
|
1905
|
+
record stores the file's sha256 and mtime and claims nothing more — and a hand-written cucumber
|
|
1906
|
+
array was always exactly as easy to write as this is.
|
|
1907
|
+
|
|
1908
|
+
Parsing is strict where the cucumber reader is tolerant, for the reason `--contract-results` is: a
|
|
1909
|
+
cucumber report is another tool's file full of entries loam has no business judging, while this shape
|
|
1910
|
+
exists for one purpose, so a malformed entry is a malformed report rather than scenery to skip. A
|
|
1911
|
+
`status` of `skipped` or `pending` is **refused, not guessed** — a scenario that did not run has no
|
|
1912
|
+
place in an answer sheet, and omitting it leaves its claim unanswered rather than confirmed. A file
|
|
1913
|
+
carrying the marker is always graded as this shape, even when malformed: falling through to the
|
|
1914
|
+
cucumber parser would tell an author their report is not a cucumber array, which is true, useless,
|
|
1915
|
+
and hides the real mistake.
|
|
1916
|
+
|
|
1917
|
+
**Two services wording a scenario identically share one digest**, and a single report then cannot
|
|
1918
|
+
say whose suite ran it. Those claims are left `unconfirmed` with `verify.digest-contested` (warn)
|
|
1919
|
+
naming both services and the affected claim ids, rather than confirming both from one run. Record
|
|
1920
|
+
each service's claims from its own repository with `--service`, which narrows the checklist before
|
|
1921
|
+
matching. Scenario digests are salted by *axis* (business versus arch) but **not** by service, so a
|
|
1922
|
+
digest identifies scenario words; `--service` is what gives a report an owner.
|
|
1923
|
+
|
|
1924
|
+
### Contract-test reports answer the `api.exposes` claims
|
|
1925
|
+
|
|
1926
|
+
**`--contract-results <contract.json>` answers the `api.exposes` claims mechanically, from an API
|
|
1927
|
+
contract-test report.** The accepted format is loam's own documented shape — a JSON object with the
|
|
1928
|
+
marker key and one entry per exercised operation:
|
|
313
1929
|
|
|
314
|
-
|
|
1930
|
+
```json
|
|
1931
|
+
{
|
|
1932
|
+
"loamContractReport": 1,
|
|
1933
|
+
"results": [
|
|
1934
|
+
{ "operationId": "createSplit", "status": "passed", "test": "POST /splits responds 201 per contract" },
|
|
1935
|
+
{ "operationId": "getSplit", "status": "failed" }
|
|
1936
|
+
]
|
|
1937
|
+
}
|
|
1938
|
+
```
|
|
315
1939
|
|
|
316
|
-
|
|
1940
|
+
`loamContractReport: 1` is the format version this loam reads (any other value refuses — a later
|
|
1941
|
+
version may redefine what an entry means, and reading it with this parser could confirm something
|
|
1942
|
+
the report never said). Every entry must carry a non-empty string `operationId` and `status`; `test`
|
|
1943
|
+
is optional free text that rides into the evidence line; extra keys pass untouched. Unlike the
|
|
1944
|
+
cucumber reader, which tolerates everything except the shape it matches on because a cucumber report
|
|
1945
|
+
is another tool's file, this shape exists for exactly one purpose, so a malformed entry refuses the
|
|
1946
|
+
whole file (`answers-unreadable`, naming the entry) — an unparseable report never silently answers
|
|
1947
|
+
zero claims as if none matched.
|
|
1948
|
+
|
|
1949
|
+
**Matching is by operationId and nothing else**, against the operationId each `api.exposes` claim
|
|
1950
|
+
asserts — and that join is only half an identity, which the rules below are honest about. An
|
|
1951
|
+
operationId is unique within *one* contract document, not across a fleet, and a report entry names
|
|
1952
|
+
no service; what makes the match safe is scope, not string precision: `--service` narrows the
|
|
1953
|
+
checklist to one subject before the matching starts, exactly as it does for scenario digests. **An
|
|
1954
|
+
operationId that more than one service on the checklist exposes is contested**: no report can say
|
|
1955
|
+
whose suite exercised it, so those claims are left `unconfirmed` with `verify.operation-contested`
|
|
1956
|
+
(warn) naming the services and the affected claim ids — `verify.digest-contested`'s rule on this
|
|
1957
|
+
axis — and recording each service's claims from its own repository with `--service` is the
|
|
1958
|
+
resolution. Within one service the match is exact, and renaming an operation renames its claim, so
|
|
1959
|
+
rewording cannot carry an answer over. The verdicts: every entry naming the claim's operationId (an
|
|
1960
|
+
operationId held twice is two test cases, and all of them count) has status exactly `"passed"` →
|
|
1961
|
+
`confirmed`, with evidence `<report-path>: <test text, or "operationId '…' passed">` per entry; a
|
|
1962
|
+
`"failed"` or unknown status → `unconfirmed` with the status in the note; no entry at all →
|
|
1963
|
+
`unconfirmed` ("not exercised"). Entries naming operations outside the checklist are skipped
|
|
1964
|
+
silently — a real contract suite exercises the whole living API, and the claims only cover what the
|
|
1965
|
+
feature adds.
|
|
1966
|
+
|
|
1967
|
+
The vendor formats were surveyed and none is parsed directly, by decision recorded here so it is not
|
|
1968
|
+
re-litigated by accident: **Specmatic's JSON coverage report is declined by name** because its
|
|
1969
|
+
`coverageStatus` value `"covered"` means *exercised*, not *passed* — a red contract-test run still
|
|
1970
|
+
writes `"covered"`, so an adapter over it would confirm exactly what a failing suite refuted;
|
|
1971
|
+
Specmatic's newer CTRF test report carries honest pass/fail but no structured operation identity
|
|
1972
|
+
loam could join on. Pact is keyed by interaction descriptions and provider states, Dredd emits
|
|
1973
|
+
TAP/JUnit first, and no property-based framework's native output carries an operation join key at
|
|
1974
|
+
all. Emit the generic shape from any of them instead — from a report that knows the operationId and
|
|
1975
|
+
whether its test passed, it is a one-line transform (for example, over a Specmatic CTRF report: map
|
|
1976
|
+
each test to `{"operationId": …, "status": "passed"|"failed"}` and wrap in
|
|
1977
|
+
`{"loamContractReport": 1, "results": […]}`).
|
|
1978
|
+
|
|
1979
|
+
**`--contract-results` OWNS every `api.exposes` claim in the same run**, exactly as `--results` owns
|
|
1980
|
+
the scenario claims: an answers-file entry for one is refused (`answers-mismatch` — the report
|
|
1981
|
+
answers those), and a mechanical flag alone is refused while claims outside its kind are
|
|
1982
|
+
outstanding. The ownership is deliberately total: a partial contract suite leaves its unexercised
|
|
1983
|
+
operations `unconfirmed` rather than agent-attestable in the same run, because silence must not read
|
|
1984
|
+
as checked — the escape is a `--record` run without the flag. On an archived feature the flag
|
|
1985
|
+
refuses exactly as `--record` does.
|
|
1986
|
+
|
|
1987
|
+
The record pins **which contract report it read**: a `contractReport:` block with the file's path,
|
|
1988
|
+
the sha256 of its bytes, its mtime, the count of distinct operationIds it names and the format it
|
|
1989
|
+
was read as — on the record for the all-at-once form, and filed with that repository's attestation
|
|
1990
|
+
in the federated form, pruned with its answers, beside `report:` and under its rules: in federated
|
|
1991
|
+
mode the file must resolve *inside* the attesting repository, and one that is committed there and
|
|
1992
|
+
differs from the attested commit is refused by name. The same honesty applies: the digest identifies
|
|
1993
|
+
which file was consumed; nothing proves the file came from executing the attested commit, and no
|
|
1994
|
+
digest can.
|
|
1995
|
+
|
|
1996
|
+
**The verdict derivation does not move.** A contract-confirmed `api.exposes` claim counts toward
|
|
1997
|
+
`verified` exactly as an agent-confirmed one always has — with strictly more provenance behind it:
|
|
1998
|
+
`answered_by: external-runner` on the claim, `[contract]` on the printed line, and the pinned
|
|
1999
|
+
`contractReport:` block. The attested boundary is exclusively about `scenario.tested` claims, which
|
|
2000
|
+
a contract report can never answer, so `attested` versus `verified` turns on scenario claims alone,
|
|
2001
|
+
before and after this flag.
|
|
2002
|
+
|
|
2003
|
+
### Cross-examination: `--diff-answers` compares two blind answer sets
|
|
2004
|
+
|
|
2005
|
+
**`--diff-answers a.json b.json` mechanically cross-examines two independently written answer sets
|
|
2006
|
+
against the same current checklist, and writes nothing.** Both files pass the full `--record`
|
|
2007
|
+
validation — `answers-unreadable` / `answers-mismatch` / `answers-unevidenced`, the refusal naming
|
|
2008
|
+
which file ("first answer set (a.json): …") — so each must answer **every** claim in the compared
|
|
2009
|
+
scope, `scenario.tested` included: the diff consults no test report, which makes the
|
|
2010
|
+
cross-examination file a different artifact from the answers file a `--results` recording takes
|
|
2011
|
+
(that one must omit the runner's claims). `--service <id>` narrows the compared claims to one
|
|
2012
|
+
service's as a pure lens — no repository binding, usable from the docs repo, refusing
|
|
2013
|
+
`unknown-service` when the checklist has no claims owned by that id — and both agents must have
|
|
2014
|
+
answered the same scope. Combining the flag with `--record`, `--results` or `--contract-results`
|
|
2015
|
+
refuses `invalid-option`, as does any arity other than exactly two files and an archived feature
|
|
2016
|
+
(frozen history has no current checklist to answer). A `verification.yaml` too damaged to read does
|
|
2017
|
+
**not** block the diff — the lens never reads the record, and the moment a cross-examination is most
|
|
2018
|
+
needed is before re-recording over a broken one.
|
|
2019
|
+
|
|
2020
|
+
The join is by claim id and nothing else — the same deterministic ids that make two runs diffable
|
|
2021
|
+
make two blind answer sets joinable. Per claim the payload reports `cross.agree-confirmed` (with
|
|
2022
|
+
`evidenceDisjoint: true` when the two cited file sets share no path; a trailing `:line` or
|
|
2023
|
+
`:start-end` suffix is normalized away first, so two citations into one file never read as
|
|
2024
|
+
disjoint), `cross.agree-unconfirmed`, or `cross.disagree`. `disagreements[]` repeats the disagreeing
|
|
2025
|
+
rows first-class, and the `cross.evidence-disjoint` notice (warn) lists the agreed-confirmed claims
|
|
2026
|
+
whose citations do not overlap — the second thing to read, after the disagreements. The exit code is
|
|
2027
|
+
0 with `ok: true` whenever both files validate — disagreement is data, not failure — and the `files`
|
|
2028
|
+
block pins each file's path and the sha256 of the exact bytes read.
|
|
2029
|
+
|
|
2030
|
+
**Under `--diff-answers`, `claims[]` and `summary` are different shapes from ordinary
|
|
2031
|
+
`verify --json` under the same keys**: a row carries the comparison code and both sides' answers
|
|
2032
|
+
(`a` / `b`) instead of a single verdict, and `summary` counts agreements and disagreements, not
|
|
2033
|
+
confirmed/unconfirmed/unanswered. The payload also deliberately carries **no `verified`, `verdict`
|
|
2034
|
+
or `attested` key at all**: two answer sets agreeing — usually two contexts of the same model, so
|
|
2035
|
+
correlated — ranks where a reviewer reads first; it is not verification, it upgrades nothing, and it
|
|
2036
|
+
leaves `verification.yaml` byte-identical (no docs lock is taken, and the diff runs while a writer
|
|
2037
|
+
holds one).
|
|
317
2038
|
|
|
318
2039
|
### Attested is not verified
|
|
319
2040
|
|
|
320
|
-
A service with no runnable suite yet must still be able to record its answers, so `--record` may
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
-
|
|
325
|
-
|
|
326
|
-
- the
|
|
327
|
-
|
|
328
|
-
|
|
2041
|
+
A service with no runnable suite yet must still be able to record its answers, so `--record` may
|
|
2042
|
+
confirm a `scenario.tested` claim too. That path is kept and made **visible**, which is the whole of
|
|
2043
|
+
the decision:
|
|
2044
|
+
|
|
2045
|
+
- the claim's `answered_by` reads `agent` rather than `runner` (an answer written before `--results`
|
|
2046
|
+
existed points at no run, so an absent `answered_by` counts as `agent`);
|
|
2047
|
+
- the record's **`verdict`** is one of three values, recomputed from `claims[]` on every read and
|
|
2048
|
+
never taken from the `summary:` block: `verified` (every claim confirmed, every scenario claim
|
|
2049
|
+
from a run, and at least one claim exists), `attested` (every claim confirmed, but at least one
|
|
2050
|
+
scenario claim rests on an agent's word), `unverified` (anything else). `verified` in `--json` is
|
|
2051
|
+
exactly `verdict === "verified"`, so a record with zero claims can no longer read as verified by
|
|
2052
|
+
arithmetic;
|
|
2053
|
+
- `attested` (a count) and `notices[]` ride in the same payload, `verify` prints
|
|
2054
|
+
`verify.scenario-attested` (**warning**, gating nothing) naming each claim, and the closing line
|
|
2055
|
+
says the record travels into `features/archive/` saying so. A record that leaves claims
|
|
2056
|
+
unconfirmed or unanswered carries `verify.claims-open` (**warning**, gating nothing) in the same
|
|
2057
|
+
`notices[]`, on the read view and the frozen post-archive view alike — the one-line "not a clean
|
|
2058
|
+
result" summary with all four counts; a feature with no record never carries it, because
|
|
2059
|
+
not-started is not partial;
|
|
2060
|
+
- `loam status` re-reports the same finding in `checks.issues[]` and offers `next.verify-attested`;
|
|
2061
|
+
a feature in that state reaches `stage: ready`, never `done`;
|
|
2062
|
+
- the frozen post-archive view recounts from `claims[]` as well. The record's own `summary:` is
|
|
2063
|
+
still echoed verbatim in `--json` — that is the frozen view's promise — but nothing is *derived*
|
|
2064
|
+
from it, and a `summary` that contradicts its own `claims[]` makes the whole record
|
|
2065
|
+
`record-unreadable` (`verify.record-miscounted`): the file contradicts itself, so neither half can
|
|
2066
|
+
be believed and neither is reported as fact.
|
|
2067
|
+
|
|
2068
|
+
So "a green run is the only thing that confirms a scenario" is a claim about the **record**, not
|
|
2069
|
+
about the CLI's flags: the short path still works, and it is legible afterwards as the short path.
|
|
329
2070
|
|
|
330
2071
|
### The record is federated: one file, one attestation per service
|
|
331
2072
|
|
|
332
|
-
A cross-service feature's claims belong to several services, and each service's code lives in its
|
|
333
|
-
|
|
334
|
-
|
|
2073
|
+
A cross-service feature's claims belong to several services, and each service's code lives in its
|
|
2074
|
+
own repository — so no single place can honestly answer all of them.
|
|
2075
|
+
`loam verify <FEAT> --service <id> --record answers.json`, run **in that service's own repo**,
|
|
2076
|
+
narrows the checklist to that service's claims and binds the answers to this repository's commit.
|
|
2077
|
+
The result is one `schema: 2` `verification.yaml` holding a list of **`attestations`**, one per
|
|
2078
|
+
service: the service id, the commit the answers were bound to, and that service's answered claims.
|
|
2079
|
+
|
|
2080
|
+
The merge semantics are additive and non-destructive. Recording for service B keeps service A's
|
|
2081
|
+
attestation exactly as it stood — the new record is the old attestations minus B's previous one,
|
|
2082
|
+
plus B's new one. Answers the new record does *not* carry are reported in `discarded`, tagged
|
|
2083
|
+
`unattested` (a schema-1 answer, or one whose attestation is gone) or `off-checklist` (a claim the
|
|
2084
|
+
current checklist no longer asks). Nothing is dropped silently.
|
|
2085
|
+
|
|
2086
|
+
The write behind that merge holds the same advisory lock archive does, and holds it from before the
|
|
2087
|
+
feature is resolved to after the record lands — two `--record` runs for different services serialize
|
|
2088
|
+
(the later one waits briefly, then merges over what the first left, so **both** attestations
|
|
2089
|
+
survive), and a record can no longer be written into a path an `archive` is renaming out from under
|
|
2090
|
+
it. The commit itself is staged beside the target and swapped in by one rename over the exact bytes
|
|
2091
|
+
the merge was computed from, so a kill mid-record leaves the previous record whole — never truncated
|
|
2092
|
+
YAML — and an edit that landed in between refuses instead of being buried:
|
|
2093
|
+
|
|
2094
|
+
- `docs-busy` — another writer held the lock longer than the record was willing to wait (5 s).
|
|
2095
|
+
Nothing was read or written; re-run once it finishes. The contention runs both ways: `archive`,
|
|
2096
|
+
`unarchive` and `rebase` refuse on first contact, so any of them can now answer `docs-busy`
|
|
2097
|
+
because a record is landing.
|
|
2098
|
+
- `record-raced` — the file changed between the locked read and the swap: an editor, or a writer
|
|
2099
|
+
that skipped the lock. Their bytes are untouched; re-running merges over the record as it now
|
|
2100
|
+
stands.
|
|
2101
|
+
- `merge-failed` / `rollback-incomplete` — the shared commit path's own two answers, with exactly
|
|
2102
|
+
the meanings archive gives them: the swap failed and nothing was recorded (re-running can work),
|
|
2103
|
+
or the cleanup after that failure also failed and the named file needs a human.
|
|
335
2104
|
|
|
336
2105
|
Recording refuses rather than guesses, each under its own `--json` `error.code`:
|
|
337
2106
|
|
|
338
|
-
- `service-mismatch` — `--service` names a service this repository's `loam.json` does not declare. A
|
|
2107
|
+
- `service-mismatch` — `--service` names a service this repository's `loam.json` does not declare. A
|
|
2108
|
+
repo may only attest for itself.
|
|
339
2109
|
- `unknown-service` — the named service is not part of this feature's checklist.
|
|
340
|
-
- `repository-unavailable` — `--service` from a repo that declares none, or one whose commit cannot
|
|
341
|
-
|
|
2110
|
+
- `repository-unavailable` — `--service` from a repo that declares none, or one whose commit cannot
|
|
2111
|
+
be read. An attestation with nothing to bind to is not an attestation.
|
|
2112
|
+
- **`record-federated`** — the legacy all-at-once form (`--record` with **no** `--service`) over a
|
|
2113
|
+
record that already carries attestations. That form writes the whole file from one place, so it
|
|
2114
|
+
would erase every other repository's evidence; loam refuses outright and names the attestors, and
|
|
2115
|
+
never silently migrates or merges. Re-run with `--service`.
|
|
342
2116
|
- `record-unreadable` — see below.
|
|
343
2117
|
|
|
344
|
-
Reading is unrestricted: `loam verify <FEAT> --service <id>` **without** `--record` is a pure lens
|
|
345
|
-
|
|
346
|
-
|
|
2118
|
+
Reading is unrestricted: `loam verify <FEAT> --service <id>` **without** `--record` is a pure lens
|
|
2119
|
+
on the checklist, writes nothing, and works from the docs repo too. Every run reports all subjects
|
|
2120
|
+
in `services`, which is how you find out which repositories still owe an attestation.
|
|
2121
|
+
|
|
2122
|
+
The record is YAML, like the other data artifacts, and holds the claim text next to each verdict so
|
|
2123
|
+
it reads without loam. Because it has to survive without loam, human edits are expected — and read
|
|
2124
|
+
back defensively. A file that exists but does not read as a record (broken YAML, or a shape missing
|
|
2125
|
+
what the readers use — `summary` with its counts, `recorded`, `checklist`, claims each with an `id`
|
|
2126
|
+
and a known `verdict`) is **`record-unreadable`**: reported with the YAML line, and **never
|
|
2127
|
+
overwritten**. It used to count as *no record at all*, which was the wrong direction on the one
|
|
2128
|
+
artifact loam cannot reconstruct — "no record" invites writing a fresh one over evidence somebody's
|
|
2129
|
+
edit merely broke. Only a genuinely absent file is "no verification record". Extra keys a human adds
|
|
2130
|
+
still pass through untouched. Every verdict names who answered it — `answered_by: runner` (a
|
|
2131
|
+
cucumber report's green run, mechanical), `answered_by: external-runner` (a contract-test report's
|
|
2132
|
+
operationId-matched green entry, mechanical too), or `answered_by: agent` (somebody's word about the
|
|
2133
|
+
code) — so a reviewer can tell them apart at a glance:
|
|
347
2134
|
|
|
348
2135
|
```yaml
|
|
349
2136
|
feature: FEAT-101
|
|
@@ -365,36 +2152,290 @@ claims:
|
|
|
365
2152
|
note: "failed at step 3 (report.json: features/loam/split-a-payment.feature › Split across two payees)"
|
|
366
2153
|
```
|
|
367
2154
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
## Operating at fleet scale
|
|
373
|
-
|
|
374
|
-
One docs repo, a hundred services, many teams. The repo scales the way any shared codebase does — through the forge, not through loam features:
|
|
375
|
-
|
|
376
|
-
- **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.
|
|
377
|
-
- **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.
|
|
378
|
-
- **`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.
|
|
379
|
-
- **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.
|
|
380
|
-
- **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` in the docs repo for the fleet map, `npx likec4 start services/<id>` for one service model — 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. It also scaffolds a `likec4.config.json` that scopes the root LikeC4 project to `architecture/`: loam parses every `.likec4` file **alone**, so each one declares its own `specification` block, and a renderer given the whole tree at once merges them and reports every declaration as a duplicate.) `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.
|
|
381
|
-
|
|
382
|
-
Four read-only fleet/transition views make that filesystem contract inspectable without creating or repairing anything:
|
|
2155
|
+
**Federated agent answers carry `evidence_pins`.** When `--service --record` accepts a `confirmed`
|
|
2156
|
+
agent answer, each of its `file:line` citations is stamped with a pin computed from the attested
|
|
2157
|
+
commit's own blob — the same fetch that just validated the evidence, zero extra git reads:
|
|
383
2158
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
2159
|
+
```yaml
|
|
2160
|
+
evidence: [src/split/Api.ts:42]
|
|
2161
|
+
evidence_pins:
|
|
2162
|
+
- path: src/split/Api.ts
|
|
2163
|
+
line: 42
|
|
2164
|
+
file_sha256: 9f2c… # sha256 of the whole file at the attested commit, CRLF-normalized
|
|
2165
|
+
text: "createSplit(request: SplitRequest): Promise<Split>"
|
|
2166
|
+
token: createSplit # the literal the claim asserts
|
|
2167
|
+
```
|
|
387
2168
|
|
|
388
|
-
|
|
2169
|
+
The recipe, exactly as implemented: `file_sha256` is sha256 hex over the blob's text with CRLF/CR
|
|
2170
|
+
normalized to LF — git's eol config makes blob bytes and working-tree bytes legally differ on a
|
|
2171
|
+
Windows checkout, and a pin must convict content change, never checkout config (the symmetric cost
|
|
2172
|
+
is accepted: a pure line-ending change is invisible to the lint). `text` is the cited line, trimmed,
|
|
2173
|
+
capped at 200 characters. `token` is stamped from the claim's own structural fields at record time —
|
|
2174
|
+
the operationId for `api.exposes`, the message name for `event.declares`, the edge's op for
|
|
2175
|
+
`c4.calls` — never parsed back out of claim prose, absent for the kinds that assert no literal, and
|
|
2176
|
+
absent when the attested blob does not contain it: that absence is warned once, at record time,
|
|
2177
|
+
where the answerer can still act, and is deliberately not re-litigated by every later `validate` (a
|
|
2178
|
+
call site that never spelled the provider's operationId would otherwise keep a repo yellow forever,
|
|
2179
|
+
with a re-record that re-derives the same state as the only offered repair). Runner-answered claims
|
|
2180
|
+
carry no pins (their evidence names a report entry, not a file), and the legacy all-at-once form
|
|
2181
|
+
stamps none (it binds to no commit). Be precise about what a pin proves: it identifies WHAT was
|
|
2182
|
+
cited at the attested commit; it does not prove the claim true — a faithful pin of the wrong line is
|
|
2183
|
+
still a wrong answer.
|
|
2184
|
+
|
|
2185
|
+
Reading pins back is the report-pin rule again: absent is fine — every record written before pins
|
|
2186
|
+
existed, and every runner answer — but present must be whole, and a pins block that does not read
|
|
2187
|
+
makes the whole record `record-unreadable`, reported and never overwritten. One notice rides beside
|
|
2188
|
+
the stamp at record time: `verify.evidence-token-missing` (warn, verdict-neutral, gating nothing)
|
|
2189
|
+
says a cited blob at the attested commit does not contain the claim's token — fired there because
|
|
2190
|
+
that is when the answerer can still re-read the evidence.
|
|
2191
|
+
|
|
2192
|
+
Later, `loam validate` run in the service's own repository re-checks every pin on the ACTIVE feature
|
|
2193
|
+
records that service has attested, against the working tree, and reports drift: ok
|
|
2194
|
+
`evidence.checked` (every pin resolved clean) and `evidence.unpinned` (a record predating pins — the
|
|
2195
|
+
next `--record` makes it drift-checkable), warn `evidence.unresolved` (the cited file is gone,
|
|
2196
|
+
unsafe, not a regular file, or the cited line is past its end), `evidence.moved` (the file's digest
|
|
2197
|
+
differs while the cited line still reads as pinned), `evidence.line-changed` (the cited line no
|
|
2198
|
+
longer says what was recorded), `evidence.token-missing` (the file contained the token at the
|
|
2199
|
+
attested commit and no longer does) and `evidence.record-unreadable` (a verification.yaml that
|
|
2200
|
+
exists but cannot be read — none of its evidence was checked, and a silent skip would read as clean,
|
|
2201
|
+
on the very file a hand edit to the pins most plausibly breaks). Archived records are deliberately
|
|
2202
|
+
not linted — frozen history, and post-ship code drift is normal evolution. From the docs repo the
|
|
2203
|
+
family is silent: nothing there can resolve the citations, and `sources.unverifiable-from-here`
|
|
2204
|
+
already names that blind spot. The lints demote reviewer confidence only — no pin state moves a
|
|
2205
|
+
verdict in either direction, `attested` never silently becomes `verified`, and the warnings never
|
|
2206
|
+
fail `validate`; `--strict` remains the CI lever it always was.
|
|
2207
|
+
|
|
2208
|
+
It lives inside the feature, so `archive` carries it into `features/archive/<FEAT>/` with everything
|
|
2209
|
+
else. `loam verify` re-run later compares `checklist` against the current digest and reports the
|
|
2210
|
+
record as **stale** if the feature moved under it — while the feature is active. Once archived there
|
|
2211
|
+
is no current checklist to be stale against (the merge itself moved the feature's operations into
|
|
2212
|
+
the living OpenAPI, so a re-derived checklist could only disagree): `loam verify` on an archived
|
|
2213
|
+
feature renders the record verbatim as **frozen history** (`frozen: true` under `--json`, no
|
|
2214
|
+
staleness judgment), and `--record` refuses (`invalid-option` — there is no current checklist for
|
|
2215
|
+
the answers to answer): the record is about the words that shipped, and it stays that way.
|
|
2216
|
+
|
|
2217
|
+
**Verification does not gate `archive`.** Coherence gates because loam computed it from the
|
|
2218
|
+
documents; a verdict is an agent's word about code loam never read, and a gate in front of shipping
|
|
2219
|
+
teaches everyone that the cheapest way past it is to say yes. The record is for the reviewer who
|
|
2220
|
+
comes later, and an `unconfirmed` claim with a note is worth more there than a `confirmed` nobody
|
|
2221
|
+
can back up. `loam verify` exits 0 either way; branch on `verified` in the `--json` payload, or on
|
|
2222
|
+
`verdict` when you need to tell `attested` from `unverified`.
|
|
389
2223
|
|
|
390
|
-
|
|
391
|
-
- **`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.
|
|
392
|
-
- **`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.
|
|
393
|
-
- **`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.
|
|
2224
|
+
## Operating at fleet scale
|
|
394
2225
|
|
|
395
|
-
|
|
2226
|
+
One docs repo, a hundred services, many teams. The repo scales the way any shared codebase does —
|
|
2227
|
+
through the forge, not through loam features:
|
|
2228
|
+
|
|
2229
|
+
- **The docs repo bootstraps itself.** In the empty directory that will hold it,
|
|
2230
|
+
`loam init --docs . --create` scaffolds `services/`, `features/`, `architecture/landscape.likec4`,
|
|
2231
|
+
`AGENTS.md` **and the docs repo's own `loam.json`** (`{"docsDir": "."}`) — which is what makes
|
|
2232
|
+
`loam validate --all`, `loam list` and `loam archive` work when run *inside* the docs repo, rather
|
|
2233
|
+
than only from a service repo pointing at it. Commit all of it. Each service repo then joins with
|
|
2234
|
+
`loam init --docs ../docs-repo --service <id>` (no `--create`: it must find an existing docs repo)
|
|
2235
|
+
and confirms the wiring with `loam doctor`. There is no batch onboarding command and no fleet
|
|
2236
|
+
manifest: the wiring is one committed file per repo, reviewable like anything else, and N repos
|
|
2237
|
+
are N independent one-line commits rather than one command nobody can audit.
|
|
2238
|
+
- **Ownership is CODEOWNERS.** `services/<svc>/**` belongs to the owning team; `features/**` to
|
|
2239
|
+
whoever drives cross-service change, with the owners of each touched service pulled in through the
|
|
2240
|
+
feature's `specs/<svc>/` paths. loam has no permission model and should not grow one — the forge
|
|
2241
|
+
already has it.
|
|
2242
|
+
- **`vouch` and `archive` land through reviewed PRs.** Both rewrite the source of truth, and the PR
|
|
2243
|
+
supplies what loam does not record: who *accepted* the change, and the diff they saw.
|
|
2244
|
+
`vouched_by:` records the voucher's own git identity — the person who ran `loam vouch` in the
|
|
2245
|
+
service repo (see the frontmatter contract above) — which is a different person from the reviewer
|
|
2246
|
+
whenever the vouch lands through a PR, and the PR review is where that second identity lives.
|
|
2247
|
+
- **Provenance runs where the code is.** `sources` resolve only inside a service's own repo, so each
|
|
2248
|
+
service repo's CI runs `loam validate --service <id> --json` and branches on the finding codes
|
|
2249
|
+
(`sources.current`, `sources.stale`, `sources.unvouched`). The docs repo's CI runs
|
|
2250
|
+
`loam validate --all`, which counts what it cannot check from there
|
|
2251
|
+
(`sourcesUnverifiableFromHere`). Aggregating the per-repo results into a fleet view happens
|
|
2252
|
+
outside loam, on purpose: the stable codes are the interface, and any CI system can pivot on them.
|
|
2253
|
+
- **A finding is addressable data.** JSON findings always carry `locations[]`. A producer that can
|
|
2254
|
+
prove a file, line, column or structural pointer emits a `primary` location and may add `related`
|
|
2255
|
+
ones; otherwise the serializer supplies the smallest `scope` path of the service or feature target.
|
|
2256
|
+
Consumers never need to mine a path out of `message` prose.
|
|
2257
|
+
- **What it costs, measured.** On a generated 120-service fleet with 400 and 800 op-linked landscape
|
|
2258
|
+
edges (measured 2026-08: compiled binary, 8-core laptop, wall clock including Node startup):
|
|
2259
|
+
`loam list --json` ~0.45 s, and `loam validate --service <id> --json` — the command above, the one
|
|
2260
|
+
every service repo runs — ~0.6 s at either edge count. (The README's "Fleet scale" section is the
|
|
2261
|
+
canonical home of these numbers and the measurement method; this bullet restates them for the
|
|
2262
|
+
operating model and must move with it.) Both are flat in edge count because loam reads LikeC4's
|
|
2263
|
+
**parsed** model and never computes a view — it reads what a view **declares** (a `dynamic view`'s
|
|
2264
|
+
tags and ordered steps, already materialized by the parse loam pays for) and never what a view
|
|
2265
|
+
**shows**. It renders nothing, and view *computation* is what is superlinear in edges. (If you
|
|
2266
|
+
want diagrams, add a `views` block and render it with LikeC4's own tooling — `npx likec4 start` in
|
|
2267
|
+
the docs repo for the fleet map, `npx likec4 start services/<id>` for one service model — and
|
|
2268
|
+
scope the views, because an `include *` over a fleet-sized landscape takes minutes. `loam init`
|
|
2269
|
+
scaffolds the landscape with no `views` block for that reason: loam should not plant a diagram it
|
|
2270
|
+
never draws **— and never asks for one: a fleet that draws nothing owes no views block.** It also
|
|
2271
|
+
scaffolds a `likec4.config.json` that scopes the root LikeC4 project to `architecture/`: loam
|
|
2272
|
+
parses every `.likec4` file **alone**, so each one declares its own `specification` block, and a
|
|
2273
|
+
renderer given the whole tree at once merges them and reports every declaration as a duplicate.)
|
|
2274
|
+
`loam validate --all --json` over the same 120 services used to be the outlier at ~13–14 s — one
|
|
2275
|
+
fresh LikeC4 workspace per service; since the batched loader it parses the run's C4 documents in
|
|
2276
|
+
one shared workspace and answers in **~0.7 s median** on the committed 120-service benchmark
|
|
2277
|
+
(measured 2026-08-19, method and runs in docs/BENCHMARKS.md, canonically restated in the README's
|
|
2278
|
+
"Fleet scale" section). It is the docs repo's own CI job, and it is nobody's inner loop.
|
|
2279
|
+
|
|
2280
|
+
### The fleet scorecard
|
|
2281
|
+
|
|
2282
|
+
`loam validate --all --json` carries an additive `scorecard` key: ceiling-vs-actual pairs per axis —
|
|
2283
|
+
operations defined → governed (plus deprecated, and deprecated operations something still joins to),
|
|
2284
|
+
messages defined → linked, drawn C4 systems → covered by an arch requirement, the maturity rollup,
|
|
2285
|
+
verification verdicts with the confirmed claims split by who answered them (`claims.answered` under
|
|
2286
|
+
the verification aggregate: runner · external-runner · agent, off each answer's own `answered_by`),
|
|
2287
|
+
and each active feature's stage. The `--all` text report prints the same table after its summary
|
|
2288
|
+
footer. Every number is derived from that run's own reads; loam has no state, so week-over-week is
|
|
2289
|
+
your pipeline's job — capture the key per gate run into your metrics store and diff there:
|
|
2290
|
+
|
|
2291
|
+
```bash
|
|
2292
|
+
loam validate --all --json | jq '{at: now | todate} + .scorecard' >> fleet-scorecard.ndjson
|
|
2293
|
+
```
|
|
396
2294
|
|
|
397
|
-
|
|
2295
|
+
Two numbers are honest about a blind spot: stale source digests count only what the run standing in
|
|
2296
|
+
that repo could verify, and from the docs repo most services' `sources` are checkable only from
|
|
2297
|
+
their own repos — the companion count (sources unverifiable from here) says how many, and each
|
|
2298
|
+
service repo's own CI run is where staleness actually surfaces.
|
|
2299
|
+
|
|
2300
|
+
Four read-only fleet/transition views make that filesystem contract inspectable without creating or
|
|
2301
|
+
repairing anything:
|
|
2302
|
+
|
|
2303
|
+
- **`loam status [<FEAT>] [--json]`** answers "where does this stand and what do I do next", derived
|
|
2304
|
+
every run from the files — there is no state file to go stale. Per feature it grades each artifact
|
|
2305
|
+
`missing`/`blocked`/`draft`/`ready`/`done`, and `next[]` is ordered most-unblocking first, each
|
|
2306
|
+
entry a stable `next.*` code plus the compatible `command` hint and a typed `execution` plan:
|
|
2307
|
+
`kind`, `cwd`, `runnable`, a concrete `command` only when no placeholder remains, otherwise
|
|
2308
|
+
`needs` and (for authored files) the grading command in `after`. It is a **projection over the gates, never
|
|
2309
|
+
a second opinion**: `stage` is taken from the union of what `validate --feature` errors on and
|
|
2310
|
+
what `archive` refuses to merge (severity *or* `gates`), so status may be more pessimistic than
|
|
2311
|
+
either and is never greener than both — an invariant with a test behind it. The fleet form is
|
|
2312
|
+
capped at ten substantive steps plus a `next.elided` notice saying how many were left out, and
|
|
2313
|
+
whenever anything is outstanding its **last** entry is `next.fleet-gate` →
|
|
2314
|
+
`loam validate --all --json` (a repository with work in it must never get a list that omits the
|
|
2315
|
+
command CI runs; a fleet whose services exist and owe nothing says so instead, with
|
|
2316
|
+
`next.fleet-clean` — while a docs repo with no services and no features gets the first-hour ladder
|
|
2317
|
+
in place of that vacuously green sentence: `next.author-landscape` when the fleet map is missing
|
|
2318
|
+
or still the scaffold's untouched bytes, `next.bind-service`, `next.adopt-first`).
|
|
2319
|
+
`next.author-landscape` carries the command its own arm names — `loam init --create` when the map
|
|
2320
|
+
is absent, `loam seed --from fleet.yaml` when it is the untouched stub — and never the gate: over a
|
|
2321
|
+
repository with no services `validate --all` answers `ok: true`, so an agent running the documented
|
|
2322
|
+
loop against it would change nothing and read the identical `next[0]` on the next run. The cap is
|
|
2323
|
+
applied before the gate entry, so the gate is never the thing elided. It deliberately does not
|
|
2324
|
+
parse the landscape: that is a LikeC4 workspace spin, which is exactly the cost the fleet form
|
|
2325
|
+
refuses to pay, and `next.fleet-gate` is the handoff.
|
|
2326
|
+
|
|
2327
|
+
- **`loam show <FEAT> --json`** includes a semantic `review` pack in addition to its original feature
|
|
2328
|
+
summary: intent excerpt, exact tagged C4 objects and relationships, per-service requirement/API/
|
|
2329
|
+
event slices, dependency blockers, artifact and verification states, use-case blast radius,
|
|
2330
|
+
gating blockers, advisories and the same executable next actions. It is assembled from the shared
|
|
2331
|
+
projections and status functions, so review does not invent a second readiness verdict.
|
|
2332
|
+
|
|
2333
|
+
- **`loam doctor [--json]`** reads `loam.json` defensively (including corrupt JSON), resolves its
|
|
2334
|
+
`docsDir` against the config location, probes existence/read/write access, checks `services/`,
|
|
2335
|
+
**reads** the landscape rather than merely stat-ing it, inventories service/active-feature counts,
|
|
2336
|
+
and reports the current `service` binding. Every finding carries a `fix`. `healthy: false` and
|
|
2337
|
+
exit 1 are reserved for blockers: missing/invalid config, missing docsDir, missing `services/`,
|
|
2338
|
+
and the three states in which the fleet map cannot be read at all —
|
|
2339
|
+
`doctor.landscape-merge-conflict` (it still holds `<<<<<<<` markers: it is two halves of two
|
|
2340
|
+
different maps, and LikeC4 may even parse it), `doctor.landscape-invalid` (it does not parse) and
|
|
2341
|
+
`doctor.landscape-unreadable` (it exists but could not be read). An *absent* landscape stays a
|
|
2342
|
+
doctor warning — `loam validate --all` is where that becomes the error `landscape.missing`,
|
|
2343
|
+
because only there is it known whether any service exists to be drawn. Portability, absent/unknown
|
|
2344
|
+
service binding and access limitations are also warnings, not attempted repairs:
|
|
2345
|
+
`doctor.docs-absolute` flags a `docsDir` stored as an absolute path in a committed `loam.json`,
|
|
2346
|
+
which resolves only on the machine that ran `loam init`. The JSON result is a successful v1.0
|
|
2347
|
+
envelope even when unhealthy because the diagnosis itself ran; branch on `healthy` and
|
|
2348
|
+
`findings[].severity`.
|
|
2349
|
+
|
|
2350
|
+
Doctor also reports the **write path**, as state and as findings, so a repo whose last write did
|
|
2351
|
+
not finish is never called healthy: `doctor.commit-interrupted` (blocker — an interrupted
|
|
2352
|
+
`archive`/`unarchive`; the fix re-runs that exact command, which recovers under the lock),
|
|
2353
|
+
`doctor.commit-unreadable` (blocker — `.loam-commit` exists and cannot be parsed, so nothing can
|
|
2354
|
+
grade it), `doctor.docs-locked` (a warning while the lock's holder is alive, a blocker once the
|
|
2355
|
+
holder is a process that no longer exists on this host, because nothing will ever release it) and
|
|
2356
|
+
`doctor.staging-temps` (warning — orphaned `.loam-*.tmp` files a killed writer never linked into
|
|
2357
|
+
place; litter, not damage). The whole picture rides in `--json` as a `writePath` block.
|
|
2358
|
+
|
|
2359
|
+
And it reports the **agent surface**: which tool and profile the repo is wired for, how many command and skill
|
|
2360
|
+
files it holds, how many a newer loam would lay down (`doctor.agent-files-missing`), and how many
|
|
2361
|
+
have fallen behind (`doctor.agent-files-stale`). The staleness check reads exactly one thing — the
|
|
2362
|
+
`<!-- generated by loam vX.Y.Z -->` line every generated body opens with — and nothing else.
|
|
2363
|
+
The digest manifest is what authorizes `init` to refresh an unchanged pointer; a different body is
|
|
2364
|
+
treated as human-owned and never overwritten. A stamp is a separate claim and can be stale while
|
|
2365
|
+
every edit around it is legitimate. Which deliveries a repo holds is asked of the files rather
|
|
2366
|
+
than of a record: `loam.json` stores tools and profile, not deliveries, and a repo initialized
|
|
2367
|
+
`--no-skills` is not reported as missing the selected skills.
|
|
2368
|
+
|
|
2369
|
+
Commands and skills are two entry points into one protocol. The explicit command is the
|
|
2370
|
+
recommended deterministic chat path (`/loam-feature` → `/loam-implement` → `/loam-check` →
|
|
2371
|
+
`/loam-verify` → `/loam-ship`, with host-specific punctuation); a natural-language request may
|
|
2372
|
+
load the matching skill. Both generated files point to the same binary-owned
|
|
2373
|
+
`loam instructions` page, so choosing convenience cannot select different lifecycle rules. Once
|
|
2374
|
+
loaded, the agent drives `status.next[].execution` itself and returns to the user only for a
|
|
2375
|
+
decision or human-only action.
|
|
2376
|
+
|
|
2377
|
+
`loam-report` is a separate support protocol, generated as a command and skill in every profile
|
|
2378
|
+
but never appended to the lifecycle. It writes
|
|
2379
|
+
`loam-reports/YYYY-MM-DD-<short-slug>.md` in the current repository with expected/actual behavior,
|
|
2380
|
+
version, stable envelope and finding codes, relevant locations, write state, a minimal safe
|
|
2381
|
+
reproduction, classification and missing evidence. Before writing, the agent redacts secrets and
|
|
2382
|
+
home paths and reduces command output to the excerpt that proves the symptom. It never retries a
|
|
2383
|
+
writer merely to reproduce it, repairs files, uploads telemetry or submits the report. The output
|
|
2384
|
+
is an ordinary Markdown artifact a person may review, commit or share deliberately.
|
|
2385
|
+
- **`loam dependencies [<FEAT>] [--json]`** derives a deterministic graph from parsed active
|
|
2386
|
+
artifacts, never from validator message prose. A MODIFIED/REMOVED requirement depends on the
|
|
2387
|
+
active feature that ADDS the same stable `Requirement-ID` (or legacy exact name); an operation
|
|
2388
|
+
reference/removal depends on the active feature whose OpenAPI delta introduces that `operationId`.
|
|
2389
|
+
Two active features adding the same requirement identity or operationId are conflicts. JSON
|
|
2390
|
+
exposes `nodes`, grouped `edges[].reasons`, `conflicts`, dependency-first `order`, and
|
|
2391
|
+
strongly-connected `cycles`; a focused feature includes all of its transitive prerequisites.
|
|
2392
|
+
- **`loam audit-openspec <root> [--write-mapping <path>] [--json]`** inventories modern config/Store
|
|
2393
|
+
roots, the union of nested living and active-only capabilities, active and archived changes,
|
|
2394
|
+
per-change schema/`skip_specs`, RENAMED FROM/TO pairs and artifact dispositions. Living/active
|
|
2395
|
+
blockers determine `mechanicallyCompatible`; frozen `archiveDiagnostics` never do. A completed
|
|
2396
|
+
audit exits successfully even with findings. An explicit `--write-mapping` writes a
|
|
2397
|
+
non-overwriting versioned skeleton outside the source, bound to the canonical root and a SHA-256
|
|
2398
|
+
inventory digest **over the living tree and active changes only** — frozen history never gates, so
|
|
2399
|
+
it must not be able to invalidate a completed mapping either. Its `changes.<OpenSpec-id>` entries
|
|
2400
|
+
require a unique loam feature id and title; suggestions remain null decisions. A root holding no
|
|
2401
|
+
living spec and no active change is never `ready` (`openspec.workspace-empty`): a verdict over a
|
|
2402
|
+
corpus nobody read is the one answer this command must not be able to give.
|
|
2403
|
+
- **`loam migrate-openspec <root> --map <path> [--apply --target <empty-dir>] [--json]`** repeats
|
|
2404
|
+
the audit, verifies that source digest, resolves capability→one-or-many-service mappings (with
|
|
2405
|
+
per-requirement allocation across living and active requirements), explicit change→feature
|
|
2406
|
+
decisions, validated rename identities, and proposal/design/tasks dispositions. RENAMED FROM must
|
|
2407
|
+
resolve to exactly one living requirement; an existing `Requirement-ID` is reused, otherwise the
|
|
2408
|
+
mapping supplies one, and conflicting rename chains are refused. It is dry-run by default and
|
|
2409
|
+
never creates a target. `--apply --target` transactionally materializes **staged migration docs**
|
|
2410
|
+
— draft living service specs, active feature intents and routed delta specs, deterministic
|
|
2411
|
+
ADR/legacy outputs, an exact feature-local copy of every source change artifact, a verbatim copy
|
|
2412
|
+
of the living capability tree under `legacy/openspec/specs/`, normalized mapping, plan and
|
|
2413
|
+
follow-up blockers — into a separate empty directory, together with that directory's own
|
|
2414
|
+
`loam.json`, `AGENTS.md` and an empty `architecture/landscape.likec4`, so the staged target is a
|
|
2415
|
+
standalone docs repo the follow-up instructions can actually be run inside. The target may not sit
|
|
2416
|
+
inside a live loam fleet (`docsDir` of a governing `loam.json`, or any directory holding
|
|
2417
|
+
`architecture/landscape.likec4`); otherwise every staged feature would be enumerated as a phantom
|
|
2418
|
+
feature of the governing fleet. A `skip_specs: true` change gets intent/legacy material but no
|
|
2419
|
+
spec delta. The result remains deliberately non-green because C4/OpenAPI/provenance/vouch work is
|
|
2420
|
+
not invented.
|
|
2421
|
+
|
|
2422
|
+
**The adoption readout measures presence, not truth.** `loam list --json` grades every service on
|
|
2423
|
+
one monotone ladder — `empty` (a directory and nothing else) → `partial` (some artifacts, but not
|
|
2424
|
+
the required triple) → `documented` (`model.likec4` + `spec.md` + `openapi.yaml` all present) →
|
|
2425
|
+
`sourced` (the living spec declares `sources`) → `vouched` (`status: verified` with a
|
|
2426
|
+
`sources_digest` behind it; a vouch given after reading a recorded sample of the document reaches
|
|
2427
|
+
this rung too, and carries `vouchScope: "sampled"` on the row saying so) — as a `maturity` string
|
|
2428
|
+
per service and a fleet rollup of counts per rung, with the same rollup as a line in the text view.
|
|
2429
|
+
Every rung is derived from artifact presence and provenance state alone; COMPLETENESS of what was
|
|
2430
|
+
written is on the adopt brief's unchecked list, so no rung is called "adopted" — a service with one
|
|
2431
|
+
endpoint documented out of thirty reaches `vouched` exactly as fast as a thorough one, and only a
|
|
2432
|
+
reader can tell them apart.
|
|
2433
|
+
|
|
2434
|
+
**The per-service summary a CI pipeline publishes.** Staleness and validity are computable only one
|
|
2435
|
+
service repo at a time, and loam ships no aggregator — but a fleet view needs a defined thing to
|
|
2436
|
+
aggregate, and without one "no report" is indistinguishable from "clean". So the contract is a
|
|
2437
|
+
shape, not a tool. Each service repo's CI derives this from `loam validate --service <id> --json`
|
|
2438
|
+
and publishes it wherever the fleet view reads:
|
|
398
2439
|
|
|
399
2440
|
```json
|
|
400
2441
|
{
|
|
@@ -425,19 +2466,77 @@ loam validate --service "$SVC" --json | jq '{
|
|
|
425
2466
|
}'
|
|
426
2467
|
```
|
|
427
2468
|
|
|
428
|
-
`generatedAt` is the CI's clock, never loam's — loam output carries no timestamps, by design, so the
|
|
429
|
-
|
|
430
|
-
|
|
2469
|
+
`generatedAt` is the CI's clock, never loam's — loam output carries no timestamps, by design, so the
|
|
2470
|
+
same repo state always yields the same bytes. That is also what makes the timestamp the liveness
|
|
2471
|
+
signal: the rule is that every service repo's CI publishes this summary on every run, so a summary
|
|
2472
|
+
that is missing or whose `generatedAt` is old IS the "no report" state, and a fleet view renders it
|
|
2473
|
+
as unknown, never as clean. The derivation is pinned in `test/validate-contract.test.ts`, so the
|
|
2474
|
+
payload cannot drift out from under this recipe silently.
|
|
2475
|
+
|
|
2476
|
+
**One `landscape.likec4`, by decision.** A shared file every feature merges into sounds like a
|
|
2477
|
+
conflict factory, but archive's insertion is service-grouped and order-independent: additions land
|
|
2478
|
+
beside their service's existing elements and edges (new services in an id-sorted trailing region),
|
|
2479
|
+
so two concurrent archives touching **different** services usually splice different lines and git
|
|
2480
|
+
merges their PRs without help. What that does not eliminate is two archives touching the **same**
|
|
2481
|
+
service — those land adjacently by design, and their PRs conflict. The accepted process for now is
|
|
2482
|
+
AI-assisted human review in the PR flow — the same posture OpenSpec takes for same-capability
|
|
2483
|
+
conflicts, left to the AI and human at archive time: the resolver reads both features and
|
|
2484
|
+
hand-merges the block. That resolution has a documented cost, not a surprise: a hand-resolved
|
|
2485
|
+
landscape moves the file away from the bytes the archive's `.loam-before/` snapshot recorded, so a
|
|
2486
|
+
later `loam unarchive` of that feature refuses with `snapshot-stale` and needs `--force` — the
|
|
2487
|
+
resolver is trading undo precision for the merge, and should know it. The trigger for revisiting is
|
|
2488
|
+
written down so nobody relitigates it early: when landscape merge conflicts become routine — weekly,
|
|
2489
|
+
not monthly — move per-service internals fully into `services/<svc>/model.likec4` and thin the
|
|
2490
|
+
landscape down to top-level elements plus cross-service edges. Until then, one file keeps the fleet
|
|
2491
|
+
map one diffable document.
|
|
431
2492
|
|
|
432
2493
|
## Two flows
|
|
433
2494
|
|
|
434
|
-
- **Bootstrap (reverse):** `loam adopt --service <id>` emits a **brief**, not an extraction — the
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
2495
|
+
- **Bootstrap (reverse):** `loam adopt --service <id>` emits a **brief**, not an extraction — the
|
|
2496
|
+
target paths (and which already exist, to be diffed and never overwritten), the grammar of each
|
|
2497
|
+
artifact, what the living landscape already says about the service, the frontmatter to write, the
|
|
2498
|
+
checks `loam validate --service <id>` will then run, and the ones that do not exist. One target is
|
|
2499
|
+
not under `services/<id>/`: `architecture/landscape.likec4`, carried with `action: "edit"` and a
|
|
2500
|
+
`landscape.instruction` naming the block this service owes the fleet map, and present only while
|
|
2501
|
+
nothing in the map resolves to the service. An agent reads the code and writes draft
|
|
2502
|
+
`model.likec4` + `spec.md` + `openapi.yaml` + `adrs/`, `runbook.md`, `health.yaml`, and makes that
|
|
2503
|
+
one addition to the shared map. A human promotes `draft` -> `verified` with `loam vouch`.
|
|
2504
|
+
- **Forward (generative):** author `features/<FEAT>/delta.likec4` -> `loam delta <FEAT>` projects it
|
|
2505
|
+
per-service into work -> `loam gherkin <FEAT>` emits the scenarios as the executable `.feature`
|
|
2506
|
+
skeleton in the service's repo -> tests -> code -> `loam verify <FEAT> --service <id>` records,
|
|
2507
|
+
from each affected service's own repo, what was actually built.
|
|
2508
|
+
|
|
2509
|
+
There is no code extractor on either side, by decision. Nothing deterministic reads a service and
|
|
2510
|
+
says what its architecture means, and two generated models of the same code disagree in wording
|
|
2511
|
+
every run — a done-check built on diffing them would flap until somebody turned it off. What is
|
|
2512
|
+
deterministic is the **question**: which files, in which grammar, bound to which existing elements;
|
|
2513
|
+
which operations, which edges, which scenarios. loam owns the questions and the checking; an agent
|
|
2514
|
+
owns the reading. `sources` and `verification.yaml` are where the answers are written down.
|
|
438
2515
|
|
|
439
2516
|
## Status
|
|
440
2517
|
|
|
441
|
-
`init`, `list` / `show` / `status` (navigation and what-to-do-next), `doctor` / `dependencies`
|
|
442
|
-
|
|
443
|
-
|
|
2518
|
+
`init`, `list` / `show` / `status` (navigation and what-to-do-next), `doctor` / `dependencies`
|
|
2519
|
+
(preflight and the active-feature graph), `adopt` (the baseline brief), `new` (feature scaffolding),
|
|
2520
|
+
`validate` (C4 + requirements + OpenAPI + AsyncAPI + permissions + cross-axis coherence, single
|
|
2521
|
+
target or `--all`), `delta` (per-service projection), `gherkin` (the generated `.feature` suite,
|
|
2522
|
+
including Scenario Outlines), `rebase` (requirement, operation, asyncapi slot and asyncapi
|
|
2523
|
+
component-surface pins), `verify`,
|
|
2524
|
+
transactional `archive` / `unarchive`, `vouch`, and the OpenSpec audit/migration on-ramp are
|
|
2525
|
+
implemented, each with a `--json` contract.
|
|
2526
|
+
|
|
2527
|
+
Known and unclosed, so nobody has to discover them: the complete gate still needs repeatable CI and
|
|
2528
|
+
installed-package evidence observed from an actual push, even though behavior, coverage and the
|
|
2529
|
+
local stress runner pass. Both contract axes now merge a components-only delta: a feature
|
|
2530
|
+
asyncapi.yaml declaring no channel/operation/message slot (`components.schemas`-only, say) merges
|
|
2531
|
+
the component surfaces it declares, pinned by the root `x-loam-baselines` record, and they enter the
|
|
2532
|
+
ref sweep like anything else the merge wrote — so a `$ref` whose target the delta itself defines
|
|
2533
|
+
resolves instead of gating `asyncapi.ref-unresolved`, while one nothing anywhere defines still does.
|
|
2534
|
+
(The verification record, the multi-file writers, the OpenAPI
|
|
2535
|
+
path-item/component surfaces, the `validate --all` cost — the ~13–14 s that used to be listed here
|
|
2536
|
+
is measured at ~0.7 s, docs/BENCHMARKS.md — and the AsyncAPI feature lifecycle, all previously
|
|
2537
|
+
listed here, are closed: lock/CAS records, the journaled roll-forward transaction,
|
|
2538
|
+
`x-loam-baselines`, the batched LikeC4 workspace, and the slot-pinned asyncapi delta/merge/verify
|
|
2539
|
+
axis.) Living path-level remove markers are already reported by `validate`, and `loam list` already
|
|
2540
|
+
distinguishes `attested` from `verified`; neither is an open gap. [ROADMAP.md](ROADMAP.md) orders
|
|
2541
|
+
the remaining work by risk and gives completion criteria. `render`, health composition and UI
|
|
2542
|
+
generation are later work, not prerequisites for the integrity fixes.
|