From 79188308099dd0c3fd595bdaf93552544a7986f3 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 02:19:15 +0300 Subject: [PATCH 01/13] docs: define component runtime execution plan --- docs/component-delivery.md | 4 + docs/component-runtime-plan.md | 143 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/component-runtime-plan.md diff --git a/docs/component-delivery.md b/docs/component-delivery.md index 9a73d13..5611ff6 100644 --- a/docs/component-delivery.md +++ b/docs/component-delivery.md @@ -39,3 +39,7 @@ findings. Unexecuted checks are not evidence. Prepare the bridge commit and record its actual binary identity before component support. Component source must wait for supporting CLI. No live downstream mutation, merge or release publication belongs to this task. Related PRs state the required release order explicitly. + +The candidate [component runtime plan](component-runtime-plan.md) owns W2 CLI execution +sequencing. It can be reviewed independently from the template execution plan, but W2 +implementation still waits for clean CTR-01/ADR-002 review and the shared Solution Ready gate. diff --git a/docs/component-runtime-plan.md b/docs/component-runtime-plan.md new file mode 100644 index 0000000..3eda769 --- /dev/null +++ b/docs/component-runtime-plan.md @@ -0,0 +1,143 @@ +# Component and document runtime plan + +This is CLI #62's implementation plan for W2, owned by memory-bank-cli. It imports +[CTR-01](https://github.com/dapi/memory-bank/blob/feat/141-component-adoption/docs/components.md) +and the parent issue's acceptance contract. The template feature owns payload declarations, +base templates and flow wrappers; this file owns CLI implementation sequencing only. + +Status: candidate. Implementation waits for a clean review of CTR-01/ADR-002, this plan, and +the bridge checkpoint. Bridge candidate commit: a0811c4 (full SHA recorded with binary evidence +once independent review completes). No release or live downstream migration is included. + +## Grounding and boundaries + +Grounded CLI baseline ac7101c307e65566787bdb32a1bdad40b9a8b995 plus W1 a0811c4: + +- ownership/source.go verifies pinned regular Git blobs; W1's source_format.go classifies them. +- ownership/update.go owns run, buildPlan, mutation preconditions and applyAtomicallyPinned. +- ownership/lock.go reads schema 0/1 and validates ownership/digests/modes. +- ownership/resolution_plan.go revalidates sources in PlanPull and ApplyResolutionPlan. +- doctor/governance.go currently applies metadata and feature lifecycle checks type-wide. +- cli/cli.go dispatches init/pull/doctor/lint and keeps update reserved for the executable. +- agentinstructions/block.go preserves bytes outside the managed instruction block. + +Add a pure internal/contracts package for manifest/rule decoding and frozen validation semantics. +It must not import ownership, doctor or CLI. Ownership uses contracts during transaction preflight; +doctor/lint use the same validator, avoiding divergent activation rules. The existing legacy +validator remains available for legacy installations. Schema-2 installations use explicit adoption. + +## Steps + +1. **Composition** — internal/contracts/manifest.go and ownership/components.go validate the whole + source inventory, resolve preset/adapter dependencies, filter payload and compose README/AGENTS. + Extend Lock/Options and strict lock decoding for schema 2 without upgrading ordinary legacy + source pulls. Unknown fields/paths/capabilities, missing inventory, cycles and removals fail + before writes. Component-aware PlanPull and ApplyResolutionPlan are required in W2: + both use the same composed transaction plan and validate selection, state and current source. + Apply regenerates the entire plan before writes, so a saved pre-bridge plan receives no + exemption. A matching-identity old-format plan against a component source is a direct + negative fixture. Resolution planning is complete only after successful component preview, + application, stale-file/lock/source rejection and no-mutation tests pass. +2. **Frozen contracts** — internal/contracts/rules.go and engine.go decode base types and immutable + bundles. Each bundle embeds DNA, base and extension rules, plus the exact engine ID/digest. + The trusted implementation embeds its immutable behavior artifact and a positive/negative + corpus. Published bundles and engine behavior are never modified in place. A live DNA/base + change cannot affect an adopted document's verdict. Missing or changed required bundles fail + before payload mutation. Extensions cannot weaken their embedded base requirements. +3. **Adoption integrity** — ownership/adoption.go owns the registry snapshot, identity/path/type + reconciliation and checksum binding to lock. Scan regular Markdown only under memory-bank/ for orphan projection + fields, excluding .repo, dna, flows, templates, document-types, prompts and declared managed + template assets. Do not inspect unrelated Markdown outside that root. Reject unsafe + symlinks/aliases rather than following them. Recorded targets must resolve exactly once. Base documents have no record or marker. + Schema-2 full/legacy always have a registry, even when empty; schema-0/1 installations + remain on the old validator and have no registry requirement before explicit migration. + Missing/corrupt schema-2 state is an error, never + an invitation to recreate it. Read/validate both source and installed contracts. +4. **Document operations** — ownership/documents.go and cli/documents.go implement create, adopt, + transition and move using the same handle-relative transaction engine. Report a dry-run plan; + check applicable old/new gates and explicit evidence references; commit document, registry, + history and lock together. Same adoption/move is idempotent. Unsupported detach/delete or + transitions reject before writes. Base creation in full does not adopt implicitly. Legacy-flow + creation explicitly resolves a per-document compatibility ID rather than adding to a selector. +5. **Legacy migration** — ownership/component_migration.go verifies the source-specific supported + legacy map, ownership and local drift; freezes the prior document identities in selector + snapshots; and previews the creation/validation semantics change. Apply requires both explicit + --migrate-components and the current --migration-plan-digest. The digest binds old lock, source, + resolution map, observed document bytes/modes and proposed mutations; deterministic snapshot + identities/history ensure repeated preview is stable. Unsupported versions and incomplete, + incompatible or ambiguous owner maps conflict. Migration preserves existing invalid legacy + verdicts, while identity/integrity constraints still must hold. Selector transition atomically + adds an exclusion plus a new per-document record, with no implicit precedence. +6. **Validation entrypoints** — doctor and lint check component state, dependencies, base documents, + adoption and navigation; core/docs do not require absent Flows. Source-profile projection is + explicitly distinguished from a downstream with missing lock. Preflight validates the resulting + tree, including derived_from, Markdown paths, embedded frontmatter and priming manifest paths. + Existing project-owned content is preserved by pull; scaffold ownership transfers on creation. + Migration alone allows the same pre-existing legacy validation findings: compare multisets + of stable identity, finding code, rule ID and subject before/after under the frozen engine. + Any added or removed legacy finding, identity/integrity/path failure or new navigation violation blocks apply. + Normal validation still reports the preserved errors. Test invalid legacy migration succeeds + while an added violation fails without writes; ordinary pulls get no blanket exemption. + +Selection precedence is CTR-01's contract: fresh init without flags chooses legacy; a +flagless schema-2 pull preserves the locked preset/components/adapters exactly. An explicit +preset resolves together with retained/new adapters and dependencies, then rejects removal +of any installed component. Adapter flags are additions, never replacement. No selection +flag opts a schema-0/1 installation into component migration. With a legacy source, any +component-selection flag is rejected before writes; it is never silently ignored. With a +component source and schema-0/1 lock, selection flags without explicit migration consent +also reject before writes. The flagless case rejects identically: any schema-0/1 lock plus +a component source requires --migrate-components, including unattended pull with no preset +or adapter flags. It never implicitly changes validation semantics. A direct flagless +fixture checks byte/mode/lock preservation. Direct negative fixtures cover both source formats and preserve +every downstream byte/mode and lock. Tests repeat flagless pulls for +every preset and adapter variant and assert unchanged selection and no automatic Flows. + +Migration preview is `pull --migrate-components --dry-run --json` (with explicit source +inputs and optional --migration-resolution FILE). It writes no downstream state and returns +migration_plan_digest plus the exact proposed changes/semantics. Apply passes that digest +back as `pull --migrate-components --migration-plan-digest DIGEST` with the same source and +resolution input. Apply regenerates the preview and rejects changed observations or a stale +digest before writes. Neither unattended mode nor --preset legacy replaces this consent. + +CLI flags: init/pull --preset NAME, repeated --adapter NAME; pull --migrate-components, +--migration-plan-digest DIGEST and --migration-resolution FILE. Document commands use --type, +--path, --contract, --to, --id (required for move), --dry-run and repeatable --evidence REF as applicable. An explicit +--legacy-flow chooses the installation's pinned compatibility contract. No adapter removal, +uninstall, contract composition, automatic adoption, arbitrary code execution or global service +is introduced. Exact serialized fields and encoding rules are owned by the shared +[CTR-01 wire format](https://github.com/dapi/memory-bank/blob/feat/141-component-adoption/docs/component-wire-format.md). +Go types and producer/consumer fixtures implement it; semantic changes return to design review. + +## Verification and failure boundaries + +Go contract/transaction fixtures cover every parent acceptance class: preset/default/adapter +matrix; repeated init/pull; docs-to-full and scaffold preservation; malicious paths and symlinks; +base vs adopted feature; registry/marker/identity/type/path tampering; frozen bundle/DNA/base/engine +drift and missing historical bundle; migration opt-in and stale digest; ambiguous moved documents +with valid/invalid owner maps; legacy selector exclusion plus rollback; fresh and migrated legacy +creation; unsupported transitions; failure during staged writes and concurrent changed lock. +Fixtures retain a byte/mode snapshot of the old tree and an external sentinel for negative paths. + +Run `env -u GOROOT go test ./...`, `env -u GOROOT go vet ./...`, hermetic ownership E2E, and a +real component binary against the exact template candidate commit. Keep a separately built bridge +binary at the reviewed W1 commit; its real-binary fixture must reject the component candidate. +A pre-bridge binary is tested through the template's minimum-capability entrypoint. Direct +pre-bridge execution on a component source remains explicitly unsupported by the parent issue. + +Before each implementation step, read its grounded owner files; update this plan when the exact +surface changes. Independent code-converge review uses a clean author commit, explicit baseline +and --max-cycles 0 so the run cannot fix, checkpoint or publish reviewed changes. Author fixes and +commits findings, then re-runs the review. Separate final code and simplification passes must be +clean. CLI and template PRs retain an explicit bridge-first release dependency; publication tags +are assigned by the release owner after review, not invented as already available binaries. + +## Re-evaluated execution boundary + +After five artifact review iterations, the implementation keeps one imported wire owner, +a bounded memory-bank document scan and an explicit digest-producing preview command. +The source-format bridge remains a separate delivery checkpoint; this plan does not +advertise component capability until its complete operation matrix is implemented. +Producer/consumer fixtures must cover CTR-01 selector grouping/IDs, context-root derivation, +canonical registry bytes and exact legacy finding multiset equality. Shared contract review +and this execution-plan review are separate gates; neither is assumed complete here. From a70f654f5f31b283ab6dd39fe66c8d5eb4046802 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 02:22:27 +0300 Subject: [PATCH 02/13] refactor: avoid repeating source JSON validation --- internal/ownership/source_format.go | 4 +++- internal/ownership/source_format_test.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/ownership/source_format.go b/internal/ownership/source_format.go index f38b8e0..33d7748 100644 --- a/internal/ownership/source_format.go +++ b/internal/ownership/source_format.go @@ -60,8 +60,10 @@ func verifySourceFormat(root, ref, payloadRoot string) error { return fmt.Errorf("invalid %s: unknown field %q", SourceDeclarationFile, key) } } + // The first pass validates exact key spelling (encoding/json accepts case + // aliases), duplicate keys and trailing bytes. Only typed decoding remains. var declaration sourceDeclaration - if err := decodeSourceJSON(data, &declaration); err != nil { + if err := json.Unmarshal(data, &declaration); err != nil { return fmt.Errorf("invalid %s: %w", SourceDeclarationFile, err) } if declaration.SchemaVersion != 1 || declaration.PayloadFormat != "legacy/v1" { diff --git a/internal/ownership/source_format_test.go b/internal/ownership/source_format_test.go index 046fbc1..7a29b33 100644 --- a/internal/ownership/source_format_test.go +++ b/internal/ownership/source_format_test.go @@ -53,6 +53,8 @@ func TestSourceFormatRejectionIsNonMutating(t *testing.T) { {"unknown schema", `{"schema_version":2,"payload_format":"legacy/v1","capabilities":["legacy/v1"]}`, "", "unsupported source format"}, {"component format", `{"schema_version":1,"payload_format":"components/v1","capabilities":["components/v1"]}`, "", "unsupported source format"}, {"unknown field", `{"schema_version":1,"payload_format":"legacy/v1","capabilities":["legacy/v1"],"extra":true}`, "", "unknown field"}, + {"case-aliased field", `{"Schema_Version":1,"payload_format":"legacy/v1","capabilities":["legacy/v1"]}`, "", "unknown field"}, + {"wrong field type", `{"schema_version":"1","payload_format":"legacy/v1","capabilities":["legacy/v1"]}`, "", "cannot unmarshal"}, {"duplicate field", `{"schema_version":1,"schema_version":1,"payload_format":"legacy/v1","capabilities":["legacy/v1"]}`, "", "duplicate JSON field"}, {"trailing JSON", legacyDeclaration + `{}`, "", "trailing JSON"}, {"missing capability", `{"schema_version":1,"payload_format":"legacy/v1"}`, "", "requires capability"}, From acbfb3291d4eb63cf06a59b718d0f892f1ffaae4 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 03:35:00 +0300 Subject: [PATCH 03/13] docs: define durable component recovery execution --- docs/component-runtime-plan.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/component-runtime-plan.md b/docs/component-runtime-plan.md index 3eda769..0c2ac1a 100644 --- a/docs/component-runtime-plan.md +++ b/docs/component-runtime-plan.md @@ -6,8 +6,8 @@ and the parent issue's acceptance contract. The template feature owns payload de base templates and flow wrappers; this file owns CLI implementation sequencing only. Status: candidate. Implementation waits for a clean review of CTR-01/ADR-002, this plan, and -the bridge checkpoint. Bridge candidate commit: a0811c4 (full SHA recorded with binary evidence -once independent review completes). No release or live downstream migration is included. +the bridge checkpoint. Bridge checkpoint: ready PR #63 at 3b434fd93678c36447d10d4f308a39ce5d74b040, with +required CI, actual-binary canary and independent functional/simplification reviews clean. No release or live downstream migration is included. ## Grounding and boundaries @@ -109,6 +109,30 @@ is introduced. Exact serialized fields and encoding rules are owned by the share [CTR-01 wire format](https://github.com/dapi/memory-bank/blob/feat/141-component-adoption/docs/component-wire-format.md). Go types and producer/consumer fixtures implement it; semantic changes return to design review. +## Durable recovery extension + +The consolidated CTR-01 recovery predicate requires internal/ownership/component_recovery.go +and a component-only hook in the existing transaction engine. Before mutation, persist and +sync a versioned staging journal binding every observed/target path, before/after bytes and +modes, numbered backup mapping and created directories. Use a prepared/committed journal +state: first sync existing target-file contents and staged replacements, then sync the +prepared file, its staging directory and repository parent before target mutation. +The existing writer renames originals into numbered backups; it does not create copied +backup files before mutation. Sync both directories after each original rename and before +installing its replacement, preserving the already synced original inode at target or backup. +The journal also records directory before/after existence and modes for complete restoration; after all replacements and lock-last, sync changed files and directories, then +atomically persist/sync the committed journal and staging directory. A crash before that +last durable marker is ambiguous and requires restoring the complete before state. No new +file writer or automatic rollback replay is introduced. Before subsequent component planning, retained staging blocks writes unless +its complete before state has been restored; committed cleanup retries instead check complete +after state and integrity. Unknown journals fail closed. The repository owner performs manual restoration using the +journal's exact path-to-backup mapping and before observations: restore originals from +numbered backups or a trusted pre-operation backup, restore modes, remove originally absent +targets and created empty directories, and preserve concurrent edits separately. Recovery +checking never edits target files; it checks the restored mixed-state fixture against all +observations before cleaning staging and permitting ordinary preflight. Tests inject rollback/cleanup failure and verify that restoring only the lock is +insufficient, complete restoration permits cleanup/re-entry and retry is idempotent. + ## Verification and failure boundaries Go contract/transaction fixtures cover every parent acceptance class: preset/default/adapter From b30150b4156ed7fe8aa36767267d3934eaa8aafa Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 04:45:11 +0300 Subject: [PATCH 04/13] feat: add component contract validation primitives --- docs/component-runtime-plan.md | 7 +- go.mod | 1 + go.sum | 2 + internal/contracts/common.go | 252 +++++++++++++ internal/contracts/document.go | 309 +++++++++++++++ internal/contracts/engine.go | 203 ++++++++++ internal/contracts/engine_test.go | 132 +++++++ internal/contracts/engines/governance-v1.json | 1 + internal/contracts/legacy_parser.go | 100 +++++ internal/contracts/manifest.go | 354 ++++++++++++++++++ internal/contracts/manifest_test.go | 128 +++++++ internal/contracts/registry.go | 292 +++++++++++++++ internal/contracts/registry_test.go | 112 ++++++ internal/contracts/rules.go | 204 ++++++++++ internal/contracts/source_integration_test.go | 75 ++++ 15 files changed, 2169 insertions(+), 3 deletions(-) create mode 100644 internal/contracts/common.go create mode 100644 internal/contracts/document.go create mode 100644 internal/contracts/engine.go create mode 100644 internal/contracts/engine_test.go create mode 100644 internal/contracts/engines/governance-v1.json create mode 100644 internal/contracts/legacy_parser.go create mode 100644 internal/contracts/manifest.go create mode 100644 internal/contracts/manifest_test.go create mode 100644 internal/contracts/registry.go create mode 100644 internal/contracts/registry_test.go create mode 100644 internal/contracts/rules.go create mode 100644 internal/contracts/source_integration_test.go diff --git a/docs/component-runtime-plan.md b/docs/component-runtime-plan.md index 0c2ac1a..357b062 100644 --- a/docs/component-runtime-plan.md +++ b/docs/component-runtime-plan.md @@ -5,8 +5,9 @@ This is CLI #62's implementation plan for W2, owned by memory-bank-cli. It impor and the parent issue's acceptance contract. The template feature owns payload declarations, base templates and flow wrappers; this file owns CLI implementation sequencing only. -Status: candidate. Implementation waits for a clean review of CTR-01/ADR-002, this plan, and -the bridge checkpoint. Bridge checkpoint: ready PR #63 at 3b434fd93678c36447d10d4f308a39ce5d74b040, with +Status: active. Shared Solution Ready is satisfied by the reviewed c22294b → b94560c +contract chain (2026-09-07T01:14:17Z). This execution plan passed independent review at +acbfb32, and the bridge checkpoint is complete. Bridge checkpoint: ready PR #63 at 3b434fd93678c36447d10d4f308a39ce5d74b040, with required CI, actual-binary canary and independent functional/simplification reviews clean. No release or live downstream migration is included. ## Grounding and boundaries @@ -164,4 +165,4 @@ The source-format bridge remains a separate delivery checkpoint; this plan does advertise component capability until its complete operation matrix is implemented. Producer/consumer fixtures must cover CTR-01 selector grouping/IDs, context-root derivation, canonical registry bytes and exact legacy finding multiset equality. Shared contract review -and this execution-plan review are separate gates; neither is assumed complete here. +and this execution-plan review are separate gates; both completion checkpoints are recorded above. diff --git a/go.mod b/go.mod index 52709f7..08d6bd6 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.21 require ( golang.org/x/sys v0.17.0 golang.org/x/term v0.17.0 + golang.org/x/text v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index ab07185..f37a3ce 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/contracts/common.go b/internal/contracts/common.go new file mode 100644 index 0000000..c3ae226 --- /dev/null +++ b/internal/contracts/common.go @@ -0,0 +1,252 @@ +package contracts + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" + "io" + "path" + "reflect" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +func Digest(data []byte) string { + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} +func ValidDigest(s string) bool { + if len(s) != 71 || !strings.HasPrefix(s, "sha256:") { + return false + } + _, err := hex.DecodeString(s[7:]) + return err == nil && strings.ToLower(s) == s +} +func ValidPath(s string) bool { + if s == "" || s == "." || path.IsAbs(s) || path.Clean(s) != s || strings.HasPrefix(s, "../") || strings.ContainsAny(s, "\\\x00") || !utf8.ValidString(s) { + return false + } + for _, part := range strings.Split(s, "/") { + if strings.EqualFold(part, ".git") || strings.ContainsAny(part, `<>:"|?*`) || strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") { + return false + } + for _, r := range part { + if r < 32 || r == 127 { + return false + } + } + stem := strings.ToUpper(strings.TrimRight(strings.SplitN(part, ".", 2)[0], ". ")) + if reservedName.MatchString(stem) { + return false + } + } + return true +} + +var reservedName = regexp.MustCompile(`^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$`) + +func PortableKey(s string) string { return norm.NFC.String(cases.Fold().String(norm.NFC.String(s))) } +func CheckPortable(paths []string) error { + seen := map[string]string{} + for _, p := range paths { + if !ValidPath(p) { + return fmt.Errorf("unsafe path %q", p) + } + parts := strings.Split(p, "/") + for i := range parts { + segment := strings.Join(parts[:i+1], "/") + key := PortableKey(segment) + if previous, ok := seen[key]; ok && previous != segment { + return fmt.Errorf("portable path collision: %s and %s", previous, segment) + } + seen[key] = segment + } + } + return nil +} +func SortedSet(values []string) bool { + for i, s := range values { + if s == "" || (i > 0 && values[i-1] >= s) { + return false + } + } + return true +} +func Keys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} +func FramedID(domain string, values ...string) string { + h := sha256.New() + h.Write([]byte(domain)) + h.Write([]byte{0}) + for _, v := range values { + var length [8]byte + binary.BigEndian.PutUint64(length[:], uint64(len(v))) + h.Write(length[:]) + h.Write([]byte(v)) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// Decode rejects duplicate and case-aliased fields, null typed values, unknown +// fields, trailing values and invalid UTF-8 before typed JSON decoding. +func Decode(data []byte, target any) error { + if !utf8.Valid(data) { + return errors.New("JSON must be UTF-8") + } + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + value, err := readValue(d) + if err != nil { + return err + } + if _, err = d.Token(); err != io.EOF { + return errors.New("trailing JSON data") + } + typ := reflect.TypeOf(target) + if typ == nil || typ.Kind() != reflect.Pointer { + return errors.New("decode target must be a pointer") + } + if err = checkShape(value, typ.Elem()); err != nil { + return err + } + return json.Unmarshal(data, target) +} +func readValue(d *json.Decoder) (any, error) { + token, err := d.Token() + if err != nil { + return nil, err + } + delim, ok := token.(json.Delim) + if !ok { + return token, nil + } + switch delim { + case '{': + m := map[string]any{} + for d.More() { + key, err := d.Token() + if err != nil { + return nil, err + } + s, ok := key.(string) + if !ok { + return nil, errors.New("invalid object key") + } + if _, ok = m[s]; ok { + return nil, fmt.Errorf("duplicate JSON field %q", s) + } + v, err := readValue(d) + if err != nil { + return nil, err + } + m[s] = v + } + _, err = d.Token() + return m, err + case '[': + a := []any{} + for d.More() { + v, err := readValue(d) + if err != nil { + return nil, err + } + a = append(a, v) + } + _, err = d.Token() + return a, err + } + return nil, errors.New("unexpected JSON delimiter") +} +func checkShape(v any, t reflect.Type) error { + if t.Kind() == reflect.Pointer { + return checkShape(v, t.Elem()) + } + if v == nil { + return errors.New("null is not a typed value") + } + if reflect.PointerTo(t).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) { + return nil + } + switch t.Kind() { + case reflect.Struct: + obj, ok := v.(map[string]any) + if !ok { + return errors.New("expected object") + } + fields := map[string]reflect.Type{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name := strings.Split(f.Tag.Get("json"), ",")[0] + if name != "-" && f.IsExported() { + if name == "" { + name = f.Name + } + fields[name] = f.Type + if !strings.Contains(f.Tag.Get("json"), ",omitempty") { + if _, exists := obj[name]; !exists { + return fmt.Errorf("missing JSON field %q", name) + } + } + } + } + for key, value := range obj { + ft, ok := fields[key] + if !ok { + return fmt.Errorf("unknown JSON field %q", key) + } + if err := checkShape(value, ft); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + } + case reflect.Map: + obj, ok := v.(map[string]any) + if !ok { + return errors.New("expected map") + } + for _, value := range obj { + if err := checkShape(value, t.Elem()); err != nil { + return err + } + } + case reflect.Slice: + a, ok := v.([]any) + if !ok { + return errors.New("expected array") + } + for _, value := range a { + if err := checkShape(value, t.Elem()); err != nil { + return err + } + } + } + return nil +} + +// Canonical sorts keys even for structs and retains integer precision. +func Canonical(v any) ([]byte, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + var obj any + if err = d.Decode(&obj); err != nil { + return nil, err + } + return json.Marshal(obj) +} diff --git a/internal/contracts/document.go b/internal/contracts/document.go new file mode 100644 index 0000000..179683b --- /dev/null +++ b/internal/contracts/document.go @@ -0,0 +1,309 @@ +package contracts + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "regexp" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +type Document struct { + Path string + Raw, Body []byte + Fields map[string]any + HasFrontmatter bool + closing int + newline string + keys map[string][2]*yaml.Node +} + +func (d Document) String(key string) string { s, _ := d.Fields[key].(string); return s } +func (d Document) Has(key string) bool { _, ok := d.Fields[key]; return ok } +func ParseDocument(p string, data []byte) (Document, error) { + d := Document{Path: p, Raw: data, Body: data, Fields: map[string]any{}, newline: "\n", keys: map[string][2]*yaml.Node{}} + if !utf8.Valid(data) { + return d, errors.New("Markdown must be UTF-8") + } + start := 0 + if bytes.HasPrefix(data, []byte("---\r\n")) { + start = 5 + d.newline = "\r\n" + } else if bytes.HasPrefix(data, []byte("---\n")) { + start = 4 + } else { + return d, nil + } + d.HasFrontmatter = true + end := -1 + bodyStart := 0 + for offset := start; offset < len(data); { + next := bytes.IndexByte(data[offset:], '\n') + if next < 0 { + next = len(data) + } else { + next += offset + 1 + } + line := bytes.TrimSuffix(bytes.TrimSuffix(data[offset:next], []byte("\n")), []byte("\r")) + if bytes.Equal(line, []byte("---")) { + end = offset + bodyStart = next + break + } + offset = next + } + if end < 0 { + return d, errors.New("unterminated YAML frontmatter") + } + d.closing = end + d.Body = data[bodyStart:] + raw := data[start:end] + var node yaml.Node + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&node); err != nil && err != io.EOF { + return d, fmt.Errorf("invalid YAML: %w", err) + } + if len(node.Content) == 0 { + return d, nil + } + root := node.Content[0] + if root.Kind != yaml.MappingNode { + return d, errors.New("frontmatter must be one mapping") + } + if err := root.Decode(&d.Fields); err != nil { + return d, fmt.Errorf("invalid YAML: %w", err) + } + var extra yaml.Node + if err := decoder.Decode(&extra); err != io.EOF { + return d, errors.New("multiple YAML documents are unsupported") + } + for i := 0; i < len(root.Content); i += 2 { + k, v := root.Content[i], root.Content[i+1] + if k.Kind != yaml.ScalarNode || k.Tag != "!!str" { + return d, errors.New("frontmatter keys must be strings") + } + d.keys[k.Value] = [2]*yaml.Node{k, v} + } + return d, nil +} + +// Project preserves every unrelated byte and applies the version-1 projection +// writer, rather than round-tripping an owner's YAML through a serializer. +func Project(data []byte, updates map[string]string) ([]byte, error) { + d, err := ParseDocument("", data) + if err != nil { + return nil, err + } + order := []string{"document_type", "document_id", "flow_contract"} + for key := range updates { + if !Contains(order, key) { + return nil, fmt.Errorf("unsupported projection %s", key) + } + } + replace := map[int][]byte{} + var appendLines []byte + for _, key := range order { + value, needed := updates[key] + if !needed { + continue + } + quoted, _ := json.Marshal(value) + line := append([]byte(key+": "), quoted...) + if nodes, exists := d.keys[key]; exists { + k, v := nodes[0], nodes[1] + if k.Style != 0 || k.Column != 1 || v.Kind != yaml.ScalarNode || v.Tag != "!!str" || v.Anchor != "" || v.Line != k.Line || v.Style&(yaml.TaggedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { + return nil, fmt.Errorf("projection %s requires a plain key and single-line untagged string", key) + } + if d.String(key) == value { + continue + } + replace[k.Line+1] = line // YAML line one follows the opening delimiter. + } else { + if d.Has(key) { + return nil, fmt.Errorf("projection %s must be a direct field", key) + } + appendLines = append(appendLines, append(line, []byte(d.newline)...)...) + } + } + if !d.HasFrontmatter { + out := append([]byte("---\n"), appendLines...) + out = append(out, []byte("---\n")...) + return append(out, data...), nil + } + var out []byte + for offset, lineNo := 0, 1; offset < len(data); lineNo++ { + if offset == d.closing { + out = append(out, appendLines...) + } + next := bytes.IndexByte(data[offset:], '\n') + if next < 0 { + next = len(data) + } else { + next += offset + 1 + } + if replacement, ok := replace[lineNo]; ok { + out = append(out, replacement...) + ending := "" + if next > offset && data[next-1] == '\n' { + ending = "\n" + if next-offset > 1 && data[next-2] == '\r' { + ending = "\r\n" + } + } + out = append(out, ending...) + } else { + out = append(out, data[offset:next]...) + } + offset = next + } + return out, nil +} + +func DerivedPaths(d Document) []string { + raw, exists := d.Fields["derived_from"] + if !exists { + return nil + } + values, ok := raw.([]any) + if !ok { + values = []any{raw} + } + out := []string{} + for _, item := range values { + value := "" + switch x := item.(type) { + case string: + value = x + case map[string]any: + value, _ = x["path"].(string) + } + if strings.TrimSpace(value) != "" { + out = append(out, value) + } + } + return out +} + +var headingPattern = regexp.MustCompile(`^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$`) +var closingHeadingPattern = regexp.MustCompile(`[ \t]+#+[ \t]*$`) + +func headingParts(line string) (int, string, bool) { + m := headingPattern.FindStringSubmatch(line) + if len(m) == 0 { + return 0, "", false + } + title := strings.TrimSpace(closingHeadingPattern.ReplaceAllString(m[2], "")) + return len(m[1]), title, true +} + +// VisibleLines removes fenced blocks and HTML comments before rule matching. +func VisibleLines(data []byte) []string { + out := []string{} + fence := byte(0) + fenceLen := 0 + comment := false + fenceRun := func(line string) (byte, int, string) { + trim := strings.TrimSpace(line) + if len(trim) == 0 { + return 0, 0, "" + } + ch := trim[0] + n := 0 + if ch == '`' || ch == '~' { + for n < len(trim) && trim[n] == ch { + n++ + } + } + return ch, n, strings.TrimSpace(trim[n:]) + } + for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + if fence != 0 { + ch, n, rest := fenceRun(line) + if ch == fence && n >= fenceLen && rest == "" { + fence = 0 + } + continue + } + if !comment && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + continue + } + var visible strings.Builder + for rest := line; rest != ""; { + if comment { + i := strings.Index(rest, "-->") + if i < 0 { + break + } + rest = rest[i+3:] + comment = false + continue + } + i := strings.Index(rest, "\n```markdown\n## Hidden too\n```\n## Real ##\n") + if h := Headings(d); !reflect.DeepEqual(h, map[string]bool{"Real": true}) { + t.Fatal(h) + } +} +func TestWindowsAndUnicodePortableNames(t *testing.T) { + for _, p := range []string{"x/CON.md", "x/LPT¹.txt", "x/COM9", "x/file:stream", "x/name.", "x/name ", "x/aux .txt", "x/a\x7fb"} { + if ValidPath(p) { + t.Fatalf("accepted %q", p) + } + } + for input, want := range map[string]string{"Straße": "strasse", "Σ/ς/σ": "σ/σ/σ", "e\u0301.md": "é.md", "K": "k"} { + if got := PortableKey(input); got != want { + t.Fatalf("%q: %q", input, got) + } + } +} + +func TestFenceCommentsDoNotHideLaterHeadings(t *testing.T) { + d := document(t, "memory-bank/a.md", "---\nstatus: draft\n---\n```html\n\n") + if _, valid := DesignDecision(d); valid { + t.Fatal("modern engine treated a comment as a section") + } + if decision, valid := legacyDesignDecision(string(d.Raw)); !valid || decision != "yes" { + t.Fatal("compatibility parser changed historical behavior") + } +} diff --git a/internal/contracts/engines/governance-v1.json b/internal/contracts/engines/governance-v1.json new file mode 100644 index 0000000..2967736 --- /dev/null +++ b/internal/contracts/engines/governance-v1.json @@ -0,0 +1 @@ +{"id":"governance/v1","schema_version":1,"operators":["active_requires_upstream","feature_lifecycle","fields","sections"],"markdown":"atx-outside-fences-comments/v1","yaml":"single-top-level-mapping-duplicate-rejection/v1","legacy_classifier":"legacy-f1f04de/v1","finding_identity":"document-id-code-rule-context-relative-subject/v1","feature_lifecycle":"f1f04de-brief-context-and-design-decision/v1"} diff --git a/internal/contracts/legacy_parser.go b/internal/contracts/legacy_parser.go new file mode 100644 index 0000000..ffe5e74 --- /dev/null +++ b/internal/contracts/legacy_parser.go @@ -0,0 +1,100 @@ +// Legacy parser/operators are retained from CLI ac7101c for source f1f04de. +package contracts + +import ( + "bytes" + "fmt" + "gopkg.in/yaml.v3" + "regexp" + "strings" +) + +var ( + legacyDesignSectionHeading = regexp.MustCompile(`(?i)^\s*(#{1,6})\s+Design Requirement Decision\s*#*\s*$`) + legacyDesignHeading = regexp.MustCompile(`^\s*(#{1,6})\s+`) + legacyDecisionPattern = regexp.MustCompile("(?im)^\\s*(?:(?:[-+*]|\\d+[.)])\\s+)?(?:\\|\\s*)?`?design\\s+required\\s*:\\s*`?(yes|no)`?(?:\\s*`)?(?:\\s*\\|.*|\\s*[.,;:]?\\s*)$") +) + +func legacyDesignDecision(content string) (string, bool) { + section := legacyDesignSection(content) + matches := legacyDecisionPattern.FindAllStringSubmatch(section, -1) + if len(matches) == 0 { + return "", false + } + decision := matches[0][1] + if decision != "yes" && decision != "no" { + return "", false + } + for _, match := range matches[1:] { + if match[1] != decision { + return "", false + } + } + return decision, true +} + +func legacyDesignSection(content string) string { + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + sectionLines := []string{} + inSection := false + inFence := false + sectionDepth := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + if inFence { + continue + } + if matches := legacyDesignSectionHeading.FindStringSubmatch(line); len(matches) > 0 { + inSection = true + sectionDepth = len(matches[1]) + continue + } + if inSection { + if matches := legacyDesignHeading.FindStringSubmatch(line); len(matches) > 0 && len(matches[1]) <= sectionDepth { + return strings.Join(sectionLines, "\n") + } + sectionLines = append(sectionLines, line) + } + } + return strings.Join(sectionLines, "\n") +} + +func parseLegacyFrontmatter(data []byte) (map[string]any, bool, error) { + // YAML permits CRLF line endings. Normalize them before recognizing the + // Markdown delimiters so governed documents work consistently across + // platforms. + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + if !bytes.HasPrefix(data, []byte("---\n")) { + return nil, false, nil + } + remainder := data[4:] + end := -1 + for offset := 0; offset < len(remainder); { + candidate := bytes.Index(remainder[offset:], []byte("\n---")) + if candidate < 0 { + break + } + candidate += offset + // A delimiter must occupy its entire line. Without this check a value + // such as "---not-a-delimiter" silently closes the frontmatter. + afterDelimiter := candidate + len("\n---") + if afterDelimiter == len(remainder) || remainder[afterDelimiter] == '\n' { + end = candidate + break + } + offset = afterDelimiter + } + if end < 0 { + return nil, true, fmt.Errorf("unterminated YAML frontmatter") + } + frontmatter := map[string]any{} + decoder := yaml.NewDecoder(bytes.NewReader(remainder[:end])) + if err := decoder.Decode(&frontmatter); err != nil { + return nil, true, fmt.Errorf("invalid YAML frontmatter: %w", err) + } + return frontmatter, true, nil +} diff --git a/internal/contracts/manifest.go b/internal/contracts/manifest.go new file mode 100644 index 0000000..a2e9382 --- /dev/null +++ b/internal/contracts/manifest.go @@ -0,0 +1,354 @@ +package contracts + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strings" +) + +const ManifestPath = "memory-bank/components.json" +const RegistryPath = "memory-bank/.adoption.json" + +var contractIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*(/[a-z0-9_-]+)*/v[1-9][0-9]*$`) + +func ValidContractID(s string) bool { return contractIDPattern.MatchString(s) } + +var sourceRefPattern = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + +type Component struct { + Dependencies []string `json:"dependencies"` + Adapter bool `json:"adapter"` + Legacy bool `json:"legacy"` +} +type File struct { + Component string `json:"component"` + Ownership string `json:"ownership"` +} +type BundleRef struct { + Path string `json:"path"` + Digest string `json:"digest"` +} +type Compatibility struct { + Classifier string `json:"classifier"` + Contracts map[string]string `json:"contracts"` +} +type PathMigration struct { + To string `json:"to"` + Policy string `json:"policy"` +} +type Manifest struct { + SchemaVersion int `json:"schema_version"` + Capabilities []string `json:"capabilities"` + DNAContract string `json:"dna_contract"` + Components map[string]Component `json:"components"` + Presets map[string][]string `json:"presets"` + Files map[string]File `json:"files"` + DocumentTypes map[string]string `json:"document_types"` + Contracts map[string]BundleRef `json:"contracts"` + LegacySources map[string]Compatibility `json:"legacy_sources"` + LegacyDefaultSourceRef string `json:"legacy_default_source_ref"` + MigrationPaths map[string]PathMigration `json:"migration_paths,omitempty"` +} +type Installation struct { + Preset string `json:"preset"` + Components []string `json:"components"` + Adapters []string `json:"adapters"` + ManifestDigest string `json:"manifest_digest"` + AdoptionDigest string `json:"adoption_digest,omitempty"` + LegacySourceRef string `json:"legacy_source_ref,omitempty"` +} + +func (s Installation) Has(id string) bool { + for _, v := range append(append([]string{}, s.Components...), s.Adapters...) { + if v == id { + return true + } + } + return false +} +func ReadManifest(data []byte, inventory map[string][]byte) (Manifest, error) { + var m Manifest + if err := Decode(data, &m); err != nil { + return m, err + } + if m.SchemaVersion != 1 { + return m, errors.New("unsupported component schema") + } + if !SortedSet(m.Capabilities) { + return m, errors.New("capabilities must be a sorted set") + } + caps := map[string]bool{} + for _, c := range m.Capabilities { + switch c { + case "components/v1", "adoption/v1", "source-format/v1", "legacy/v1": + caps[c] = true + default: + return m, fmt.Errorf("unsupported capability %q", c) + } + } + if !caps["components/v1"] || !caps["adoption/v1"] { + return m, errors.New("component capabilities missing") + } + required := map[string][]string{"dna": {}, "documents": {"dna"}, "flows": {"dna", "documents"}} + for name, deps := range required { + c, ok := m.Components[name] + if !ok || c.Adapter || c.Legacy || !reflect.DeepEqual(c.Dependencies, deps) { + return m, fmt.Errorf("invalid %s component dependencies", name) + } + } + for id, c := range m.Components { + if id == "" || !SortedSet(c.Dependencies) { + return m, errors.New("invalid component or dependencies") + } + if _, ok := required[id]; !ok && !c.Adapter { + return m, fmt.Errorf("extra non-adapter component %s", id) + } + if _, err := m.closure([]string{id}); err != nil { + return m, err + } + } + if len(m.Presets) != 4 { + return m, errors.New("exactly four presets are required") + } + for p, ids := range map[string][]string{"core": {"dna"}, "docs": {"dna", "documents"}, "full": {"dna", "documents", "flows"}} { + if !reflect.DeepEqual(m.Presets[p], ids) { + return m, fmt.Errorf("invalid preset %s", p) + } + } + if !SortedSet(m.Presets["legacy"]) { + return m, errors.New("invalid legacy preset") + } + legacy, err := m.closure(m.Presets["legacy"]) + if err != nil { + return m, err + } + expected := map[string]bool{"dna": true, "documents": true, "flows": true} + for id, c := range m.Components { + if c.Legacy { + expected[id] = true + } + } + expectedClosure, err := m.closure(Keys(expected)) + if err != nil || !reflect.DeepEqual(legacy, expectedClosure) { + return m, errors.New("legacy preset does not preserve declared legacy adapters") + } + if inventory != nil && len(m.Files) != len(inventory) { + return m, errors.New("inventory does not match payload") + } + if err := CheckPortable(Keys(m.Files)); err != nil { + return m, err + } + for p, f := range m.Files { + if !ValidPath(p) || p == "AGENTS.md" || p == RegistryPath || p == "memory-bank/.lock" { + return m, fmt.Errorf("reserved or unsafe payload path %s", p) + } + if _, ok := inventory[p]; inventory != nil && !ok { + return m, fmt.Errorf("missing payload %s", p) + } + if _, ok := m.Components[f.Component]; !ok { + return m, fmt.Errorf("unknown file component %s", f.Component) + } + if f.Ownership != "managed" && f.Ownership != "user-owned" { + return m, fmt.Errorf("invalid ownership %s", p) + } + } + for _, p := range []string{ManifestPath, "memory-bank/README.md"} { + if m.Files[p] != (File{"dna", "managed"}) { + return m, fmt.Errorf("reserved composed path %s must be managed DNA", p) + } + } + if err := m.requireFile(m.DNAContract, "dna"); err != nil { + return m, err + } + if len(m.DocumentTypes) == 0 || len(m.Contracts) == 0 { + return m, errors.New("document types and contracts are required") + } + for typ, p := range m.DocumentTypes { + if typ == "" { + return m, errors.New("empty type") + } + if err := m.requireFile(p, "documents"); err != nil { + return m, err + } + } + for id, b := range m.Contracts { + if !ValidContractID(id) || !ValidDigest(b.Digest) { + return m, errors.New("invalid bundle reference") + } + if err := m.requireFile(b.Path, "flows"); err != nil { + return m, err + } + if inventory != nil && Digest(inventory[b.Path]) != b.Digest { + return m, fmt.Errorf("bundle digest mismatch: %s", id) + } + } + if _, ok := m.LegacySources[m.LegacyDefaultSourceRef]; !ok { + return m, errors.New("legacy creation baseline missing") + } + for ref, c := range m.LegacySources { + if !sourceRefPattern.MatchString(ref) || ref != "f1f04de843aef45a2425d4a7351d577bbf89e940" || c.Classifier != "legacy-f1f04de/v1" || len(c.Contracts) == 0 { + return m, errors.New("unsupported legacy classifier") + } + for typ, id := range c.Contracts { + if _, ok := m.DocumentTypes[typ]; !ok { + return m, errors.New("unknown compatibility type") + } + if _, ok := m.Contracts[id]; !ok { + return m, errors.New("missing compatibility bundle") + } + } + } + for from, migration := range m.MigrationPaths { + if migration.Policy != "retain-wrapper" || !strings.HasPrefix(from, "memory-bank/flows/templates/") { + return m, errors.New("unsupported path migration") + } + if err := m.requireFile(from, "flows"); err != nil { + return m, err + } + if err := m.requireFile(migration.To, "documents"); err != nil { + return m, err + } + } + return m, nil +} +func (m Manifest) requireFile(p, component string) error { + f, ok := m.Files[p] + if !ok || !ValidPath(p) || f.Component != component || f.Ownership != "managed" { + return fmt.Errorf("%s must be a managed %s file", p, component) + } + return nil +} +func (m Manifest) closure(ids []string) (map[string]bool, error) { + state := map[string]int{} + result := map[string]bool{} + var visit func(string) error + visit = func(id string) error { + c, ok := m.Components[id] + if !ok { + return fmt.Errorf("unknown component %q", id) + } + if state[id] == 1 { + return errors.New("component dependency cycle") + } + if state[id] == 2 { + return nil + } + state[id] = 1 + for _, dep := range c.Dependencies { + if err := visit(dep); err != nil { + return err + } + } + state[id] = 2 + result[id] = true + return nil + } + for _, id := range ids { + if err := visit(id); err != nil { + return nil, err + } + } + return result, nil +} +func (m Manifest) Select(preset string, adapters []string, old *Installation) (Installation, error) { + if old != nil && preset == "" && len(adapters) == 0 { + ids := append(append([]string{}, old.Components...), old.Adapters...) + closure, err := m.closure(ids) + if err != nil { + return Installation{}, err + } + if !reflect.DeepEqual(Keys(closure), sorted(ids)) { + return Installation{}, errors.New("source changed locked dependency closure") + } + return *old, nil + } + if preset == "" { + preset = "legacy" + if old != nil { + preset = old.Preset + } + } + ids, ok := m.Presets[preset] + if !ok { + return Installation{}, fmt.Errorf("unknown preset %q", preset) + } + ids = append([]string{}, ids...) + if old != nil { + adapters = append(append([]string{}, adapters...), old.Adapters...) + } + for _, id := range adapters { + c, ok := m.Components[id] + if !ok || !c.Adapter { + return Installation{}, fmt.Errorf("unknown adapter %q", id) + } + } + ids = append(ids, adapters...) + resolved, err := m.closure(ids) + if err != nil { + return Installation{}, err + } + if old != nil { + for _, id := range append(append([]string{}, old.Components...), old.Adapters...) { + if !resolved[id] { + return Installation{}, errors.New("component removal is unsupported") + } + } + } + s := Installation{Preset: preset, Components: []string{}, Adapters: []string{}} + for _, id := range Keys(resolved) { + if m.Components[id].Adapter { + s.Adapters = append(s.Adapters, id) + } else { + s.Components = append(s.Components, id) + } + } + if old != nil { + s.LegacySourceRef = old.LegacySourceRef + s.AdoptionDigest = old.AdoptionDigest + } + if preset == "legacy" && s.LegacySourceRef == "" { + s.LegacySourceRef = m.LegacyDefaultSourceRef + } + return s, nil +} +func sorted(values []string) []string { + seen := map[string]bool{} + for _, v := range values { + seen[v] = true + } + return Keys(seen) +} + +// ValidateInstallation checks a persisted closure rather than treating it as new opt-in. +func (m Manifest) ValidateInstallation(s Installation) error { + if !ValidDigest(s.ManifestDigest) || !SortedSet(s.Components) || !SortedSet(s.Adapters) { + return errors.New("invalid installation metadata") + } + if s.Has("flows") != ValidDigest(s.AdoptionDigest) || (!s.Has("flows") && s.AdoptionDigest != "") { + return errors.New("invalid adoption digest for selection") + } + for _, id := range s.Components { + c, ok := m.Components[id] + if !ok || c.Adapter { + return errors.New("invalid installed component") + } + } + for _, id := range s.Adapters { + c, ok := m.Components[id] + if !ok || !c.Adapter { + return errors.New("invalid installed adapter") + } + } + wanted, err := m.Select(s.Preset, s.Adapters, nil) + if err != nil || !reflect.DeepEqual(wanted.Components, s.Components) || !reflect.DeepEqual(wanted.Adapters, s.Adapters) { + return errors.New("installed closure does not match preset and adapters") + } + if s.LegacySourceRef != "" { + if _, ok := m.LegacySources[s.LegacySourceRef]; !ok || !s.Has("flows") { + return errors.New("unsupported legacy creation reference") + } + } + return nil +} diff --git a/internal/contracts/manifest_test.go b/internal/contracts/manifest_test.go new file mode 100644 index 0000000..ac3dc0c --- /dev/null +++ b/internal/contracts/manifest_test.go @@ -0,0 +1,128 @@ +package contracts + +import ( + "encoding/json" + "reflect" + "testing" +) + +func fixture() (Manifest, map[string][]byte) { + const legacy = "f1f04de843aef45a2425d4a7351d577bbf89e940" + inventory := map[string][]byte{ManifestPath: []byte("manifest"), "memory-bank/README.md": []byte("index"), "memory-bank/dna/rules.json": []byte("rules"), "memory-bank/document-types/feature.json": []byte("type"), "memory-bank/flows/contracts/feature.json": []byte("bundle"), ".codex/agents/a.toml": []byte("adapter")} + m := Manifest{SchemaVersion: 1, Capabilities: []string{"adoption/v1", "components/v1"}, DNAContract: "memory-bank/dna/rules.json", Components: map[string]Component{"dna": {Dependencies: []string{}}, "documents": {Dependencies: []string{"dna"}}, "flows": {Dependencies: []string{"dna", "documents"}}, "codex": {Dependencies: []string{"flows"}, Adapter: true, Legacy: true}}, Presets: map[string][]string{"core": {"dna"}, "docs": {"dna", "documents"}, "full": {"dna", "documents", "flows"}, "legacy": {"codex", "dna", "documents", "flows"}}, Files: map[string]File{}, DocumentTypes: map[string]string{"feature": "memory-bank/document-types/feature.json"}, Contracts: map[string]BundleRef{"feature/v1": {"memory-bank/flows/contracts/feature.json", Digest([]byte("bundle"))}}, LegacySources: map[string]Compatibility{legacy: {"legacy-f1f04de/v1", map[string]string{"feature": "feature/v1"}}}, LegacyDefaultSourceRef: legacy} + for p := range inventory { + m.Files[p] = File{"dna", "managed"} + } + m.Files["memory-bank/document-types/feature.json"] = File{"documents", "managed"} + m.Files["memory-bank/flows/contracts/feature.json"] = File{"flows", "managed"} + m.Files[".codex/agents/a.toml"] = File{"codex", "managed"} + return m, inventory +} +func TestPresetMatrix(t *testing.T) { + m, inv := fixture() + data, _ := json.Marshal(m) + parsed, err := ReadManifest(data, inv) + if err != nil { + t.Fatal(err) + } + m = parsed + for _, tc := range []struct { + preset string + adapters []string + want []string + }{{"core", nil, []string{"dna"}}, {"docs", nil, []string{"dna", "documents"}}, {"full", nil, []string{"dna", "documents", "flows"}}, {"", nil, []string{"codex", "dna", "documents", "flows"}}, {"full", []string{"codex"}, []string{"codex", "dna", "documents", "flows"}}, {"core", []string{"codex"}, []string{"codex", "dna", "documents", "flows"}}} { + s, err := m.Select(tc.preset, tc.adapters, nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(sorted(append(append([]string{}, s.Components...), s.Adapters...)), tc.want) { + t.Fatalf("%s: %#v", tc.preset, s) + } + again, err := m.Select("", nil, &s) + if err != nil || !reflect.DeepEqual(again, s) { + t.Fatalf("selection changed: %#v %v", again, err) + } + } + docs, _ := m.Select("docs", nil, nil) + if _, err := m.Select("core", nil, &docs); err == nil { + t.Fatal("accepted downgrade") + } + if full, err := m.Select("full", nil, &docs); err != nil || !full.Has("flows") { + t.Fatal("upgrade failed") + } + withAdapter, _ := m.Select("full", []string{"codex"}, nil) + if s, err := m.Select("core", nil, &withAdapter); err != nil || !s.Has("flows") { + t.Fatalf("compared before retained adapter closure: %v", err) + } +} +func TestInventoryRejections(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*Manifest, map[string][]byte) + }{ + {"unknown file", func(m *Manifest, i map[string][]byte) { i["extra"] = nil }}, + {"cycle", func(m *Manifest, i map[string][]byte) { + m.Components["codex"] = Component{Dependencies: []string{"codex"}, Adapter: true, Legacy: true} + }}, + {"manifest ownership", func(m *Manifest, i map[string][]byte) { m.Files[ManifestPath] = File{"documents", "managed"} }}, + {"bundle tamper", func(m *Manifest, i map[string][]byte) { + i["memory-bank/flows/contracts/feature.json"] = []byte("changed") + }}, + {"legacy omitted adapter", func(m *Manifest, i map[string][]byte) { m.Presets["legacy"] = []string{"dna", "documents", "flows"} }}, + {"unknown component", func(m *Manifest, i map[string][]byte) { m.Files["memory-bank/README.md"] = File{"unknown", "managed"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + m, i := fixture() + tc.edit(&m, i) + data, _ := json.Marshal(m) + if _, err := ReadManifest(data, i); err == nil { + t.Fatal("accepted invalid manifest") + } + }) + } +} +func TestPortablePaths(t *testing.T) { + for _, paths := range [][]string{{"x/Foo.md", "x/foo.md"}, {"x/é.md", "x/e\u0301.md"}, {"x/straße.md", "x/STRASSE.md"}, {"A/x.md", "a/y.md"}, {"../outside"}, {"x/.GIT/config"}, {"/absolute"}, {"x\\y"}} { + if err := CheckPortable(paths); err == nil { + t.Fatalf("accepted %q", paths) + } + } + if err := CheckPortable([]string{"x/a.md", "x/b.md"}); err != nil { + t.Fatal(err) + } +} +func TestStrictJSON(t *testing.T) { + for _, s := range []string{`{"schema_version":1,"schema_version":1}`, `{"Schema_Version":1}`, `{"schema_version":1,"unknown":false}`, `{"schema_version":"1"}`, `null`, `{} {}`, `{"schema_version":null}`} { + var m Manifest + if err := Decode([]byte(s), &m); err == nil { + t.Fatalf("accepted %s", s) + } + } +} +func TestCanonicalStructsAndFrames(t *testing.T) { + type v struct { + Z string `json:"z"` + A int `json:"a"` + } + data, err := Canonical(v{"<\n", 1}) + if err != nil || string(data) != `{"a":1,"z":"\u003c\n"}` { + t.Fatalf("%s %v", data, err) + } + if FramedID("d", "ab", "c") == FramedID("d", "a", "bc") { + t.Fatal("ambiguous frames") + } +} + +func TestUnicodeConformance(t *testing.T) { + for input, want := range map[string]string{"Foo.md": "foo.md", "e\u0301.md": "é.md", "É.md": "é.md", "Straße.md": "strasse.md", "STRASSE.md": "strasse.md", "K.md": "k.md", "Σ.md": "σ.md", "ς.md": "σ.md"} { + if got := PortableKey(input); got != want { + t.Fatalf("%q: %q != %q", input, got, want) + } + } +} +func TestMissingRequiredField(t *testing.T) { + var c Component + if err := Decode([]byte(`{"dependencies":[],"adapter":false}`), &c); err == nil { + t.Fatal("missing legacy field was accepted") + } +} diff --git a/internal/contracts/registry.go b/internal/contracts/registry.go new file mode 100644 index 0000000..8c402b8 --- /dev/null +++ b/internal/contracts/registry.go @@ -0,0 +1,292 @@ +package contracts + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var documentIDPattern = regexp.MustCompile(`^doc-[0-9a-f]{64}$`) + +type Record struct { + ID string `json:"id"` + Path string `json:"path"` + Type string `json:"type"` + ContextRoot string `json:"context_root"` + ContractID string `json:"contract_id"` + BundleDigest string `json:"bundle_digest"` +} + +func (r Record) Identity() Identity { return Identity{r.ID, r.Path, r.Type, r.ContextRoot} } + +type Selector struct { + ID string `json:"id"` + SourceRef string `json:"source_ref"` + Type string `json:"type"` + ContractID string `json:"contract_id"` + BundleDigest string `json:"bundle_digest"` + Snapshot []Identity `json:"snapshot"` + Exclusions []string `json:"exclusions"` +} +type Event struct { + Operation string `json:"operation"` + DocumentID string `json:"document_id"` + FromPath string `json:"from_path"` + ToPath string `json:"to_path"` + FromContract string `json:"from_contract"` + ToContract string `json:"to_contract"` + Evidence []string `json:"evidence"` +} +type Registry struct { + SchemaVersion int `json:"schema_version"` + Records []Record `json:"records"` + Selectors []Selector `json:"selectors"` + History []Event `json:"history"` +} +type Binding struct { + Identity Identity + ContractID, BundleDigest, SelectorID string +} + +func EmptyRegistry() Registry { return Registry{1, []Record{}, []Selector{}, []Event{}} } +func RegistryBytes(r Registry) ([]byte, error) { + b, e := Canonical(r) + if e != nil { + return nil, e + } + return append(b, '\n'), nil +} +func SelectorID(source, typ, contract, bundle string) string { + return "sel-" + FramedID("memory-bank/selector-id/v1", source, typ, contract, bundle) +} +func MigrationID(lock, p, content string) string { + return "doc-" + FramedID("memory-bank/document-id/v1", lock, p, content) +} +func ReadRegistry(data []byte) (Registry, error) { + var r Registry + e := Decode(data, &r) + if e != nil { + return r, e + } + canonical, e := RegistryBytes(r) + if e != nil || string(canonical) != string(data) { + return r, errors.New("registry must use canonical JSON plus LF") + } + return r, nil +} + +func ValidateRegistry(r Registry, c Catalog, docs map[string]Document) (map[string]Binding, error) { + active := map[string]Binding{} + all := map[string]Identity{} + records := map[string]Record{} + snapshots := map[string]Selector{} + if r.SchemaVersion != 1 { + return nil, errors.New("unsupported adoption registry schema") + } + previous := "" + checkIdentity := func(id Identity) error { + if !documentIDPattern.MatchString(id.ID) || !DocumentPath(id.Path) || !ValidPath(id.ContextRoot) || !strings.HasPrefix(id.Path, id.ContextRoot+"/") { + return errors.New("invalid document identity or context") + } + if _, ok := c.Types[id.Type]; !ok { + return errors.New("identity type is not installed") + } + return nil + } + checkBundle := func(typ, id, digest string) error { + b, ok := c.Bundles[id] + ref := c.Manifest.Contracts[id] + if !ok || b.Type != typ || ref.Digest != digest { + return fmt.Errorf("missing or changed pinned bundle %s", id) + } + return nil + } + for _, rec := range r.Records { + if rec.ID <= previous { + return nil, errors.New("records must be uniquely ID-sorted") + } + previous = rec.ID + if err := checkIdentity(rec.Identity()); err != nil { + return nil, err + } + if err := checkBundle(rec.Type, rec.ContractID, rec.BundleDigest); err != nil { + return nil, err + } + records[rec.ID] = rec + all[rec.ID] = rec.Identity() + active[rec.ID] = Binding{rec.Identity(), rec.ContractID, rec.BundleDigest, ""} + } + previous = "" + for _, sel := range r.Selectors { + if sel.ID <= previous || sel.ID != SelectorID(sel.SourceRef, sel.Type, sel.ContractID, sel.BundleDigest) || !SortedSet(sel.Exclusions) { + return nil, errors.New("invalid selector identity or ordering") + } + previous = sel.ID + compat, ok := c.Manifest.LegacySources[sel.SourceRef] + if !ok || compat.Contracts[sel.Type] != sel.ContractID { + return nil, errors.New("unsupported selector provenance") + } + if err := checkBundle(sel.Type, sel.ContractID, sel.BundleDigest); err != nil { + return nil, err + } + if !c.Bundles[sel.ContractID].Legacy { + return nil, errors.New("selector requires compatibility bundle") + } + members := map[string]bool{} + priorID := "" + for _, id := range sel.Snapshot { + if id.ID <= priorID || id.Type != sel.Type { + return nil, errors.New("invalid selector snapshot ordering/type") + } + priorID = id.ID + if err := checkIdentity(id); err != nil { + return nil, err + } + if _, duplicate := snapshots[id.ID]; duplicate { + return nil, errors.New("identity occurs in multiple selectors") + } + members[id.ID] = true + snapshots[id.ID] = sel + if rec, ok := records[id.ID]; ok { + if rec.Type != id.Type || rec.ContextRoot != id.ContextRoot || !Contains(sel.Exclusions, id.ID) { + return nil, errors.New("multiple applicable adoption records") + } + } else { + all[id.ID] = id + } + if !Contains(sel.Exclusions, id.ID) { + active[id.ID] = Binding{id, sel.ContractID, sel.BundleDigest, sel.ID} + } + } + for _, excluded := range sel.Exclusions { + if !members[excluded] { + return nil, errors.New("exclusion is not a snapshot member") + } + if _, ok := records[excluded]; !ok { + return nil, errors.New("exclusion lacks resulting record") + } + } + } + type state struct { + path, contract, initialPath, initialOperation string + selectorTransition bool + } + states := map[string]state{} + lastMigrated := "" + migrationEnded := false + for _, event := range r.History { + if event.Operation == "migrate" { + if migrationEnded || event.DocumentID <= lastMigrated { + return nil, errors.New("migration history must be an initial ID-sorted block") + } + lastMigrated = event.DocumentID + } else { + migrationEnded = true + } + if !documentIDPattern.MatchString(event.DocumentID) || !SortedSet(event.Evidence) || !DocumentPath(event.ToPath) || !ValidContractID(event.ToContract) { + return nil, errors.New("invalid history event") + } + if _, ok := all[event.DocumentID]; !ok { + return nil, errors.New("history refers to unknown identity") + } + prior, exists := states[event.DocumentID] + if !exists { + if event.FromContract != "" { + return nil, errors.New("initial event has a previous contract") + } + switch event.Operation { + case "create": + if event.FromPath != "" { + return nil, errors.New("create has a previous path") + } + case "adopt", "migrate": + if event.FromPath != event.ToPath { + return nil, errors.New("initial binding path mismatch") + } + default: + return nil, errors.New("history has no initial binding event") + } + prior.initialPath = event.ToPath + prior.initialOperation = event.Operation + sel, inSelector := snapshots[event.DocumentID] + if (event.Operation == "migrate") != inSelector || (inSelector && event.ToContract != sel.ContractID) { + return nil, errors.New("migration history does not match snapshot") + } + } else { + if event.FromPath != prior.path || event.FromContract != prior.contract { + return nil, errors.New("history continuity broken") + } + switch event.Operation { + case "move": + if event.ToPath == prior.path || event.ToContract != prior.contract { + return nil, errors.New("invalid move event") + } + case "transition": + if event.ToPath != prior.path || event.ToContract == prior.contract { + return nil, errors.New("invalid transition event") + } + if sel, ok := snapshots[event.DocumentID]; ok && event.FromContract == sel.ContractID { + prior.selectorTransition = true + } + default: + return nil, errors.New("duplicate initial or unsupported event") + } + } + id := all[event.DocumentID] + if !strings.HasPrefix(event.ToPath, id.ContextRoot+"/") { + return nil, errors.New("event escaped identity context") + } + prior.path = event.ToPath + prior.contract = event.ToContract + states[event.DocumentID] = prior + } + paths := map[string]string{} + for id, binding := range active { + current, ok := states[id] + if !ok || current.path != binding.Identity.Path || current.contract != binding.ContractID { + return nil, errors.New("history final binding disagrees with registry") + } + expectedRoot, err := ContextRoot(current.initialPath, binding.Identity.Type) + if err != nil || expectedRoot != binding.Identity.ContextRoot { + return nil, errors.New("identity context does not match initial binding") + } + if sel, ok := snapshots[id]; ok && Contains(sel.Exclusions, id) && !current.selectorTransition { + return nil, errors.New("selector exclusion lacks explicit transition") + } + if other, duplicate := paths[binding.Identity.Path]; duplicate && other != id { + return nil, errors.New("multiple identities share a path") + } + paths[binding.Identity.Path] = id + d, present := docs[binding.Identity.Path] + if !present || d.String("document_id") != id || d.String("document_type") != binding.Identity.Type { + return nil, errors.New("missing target or identity/type projection") + } + if binding.SelectorID == "" { + if d.String("flow_contract") != binding.ContractID { + return nil, errors.New("missing or changed contract projection") + } + } else if d.Has("flow_contract") && d.String("flow_contract") != binding.ContractID { + return nil, errors.New("selector contract projection mismatch") + } + } + for p, d := range docs { + if d.Has("document_type") { + typ := d.String("document_type") + if _, ok := c.Types[typ]; !ok { + return nil, fmt.Errorf("%s: unknown document type", p) + } + if d.Has("doc_kind") && d.String("doc_kind") != typ { + return nil, fmt.Errorf("%s: contradictory document kind", p) + } + } + if d.Has("document_id") || d.Has("flow_contract") { + id := d.String("document_id") + binding, ok := active[id] + if !ok || binding.Identity.Path != p { + return nil, fmt.Errorf("%s: orphan adoption projection", p) + } + } + } + return active, nil +} diff --git a/internal/contracts/registry_test.go b/internal/contracts/registry_test.go new file mode 100644 index 0000000..0b7ffe8 --- /dev/null +++ b/internal/contracts/registry_test.go @@ -0,0 +1,112 @@ +package contracts + +import ( + "strings" + "testing" +) + +func registryFixture(t *testing.T) (Registry, Catalog, map[string]Document) { + t.Helper() + m, _ := fixture() + legacyID := "legacy/f1f04de/feature/v1" + legacy, _ := LegacyBundle(legacyID, "feature") + next := legacy + next.ID = "feature/v1" + next.Legacy = false + oldHash, newHash := Digest([]byte("legacy")), Digest([]byte("new")) + m.Contracts = map[string]BundleRef{legacyID: {"legacy.json", oldHash}, next.ID: {"new.json", newHash}} + m.LegacySources[m.LegacyDefaultSourceRef] = Compatibility{"legacy-f1f04de/v1", map[string]string{"feature": legacyID}} + c := Catalog{Manifest: m, Types: map[string]DocumentType{"feature": {Type: "feature"}}, Bundles: map[string]Bundle{legacyID: legacy, next.ID: next}} + first := Identity{"doc-" + strings.Repeat("1", 64), "memory-bank/features/FT-1/brief.md", "feature", "memory-bank/features/FT-1"} + second := Identity{"doc-" + strings.Repeat("2", 64), "memory-bank/features/FT-2/brief.md", "feature", "memory-bank/features/FT-2"} + sel := Selector{SelectorID(m.LegacyDefaultSourceRef, "feature", legacyID, oldHash), m.LegacyDefaultSourceRef, "feature", legacyID, oldHash, []Identity{first, second}, []string{}} + r := EmptyRegistry() + r.Selectors = []Selector{sel} + docs := map[string]Document{} + for _, id := range []Identity{first, second} { + r.History = append(r.History, Event{"migrate", id.ID, id.Path, id.Path, "", legacyID, []string{}}) + raw, _ := Project([]byte("---\nstatus: draft\n---\n# Feature\n"), map[string]string{"document_type": "feature", "document_id": id.ID}) + docs[id.Path] = document(t, id.Path, string(raw)) + } + return r, c, docs +} +func TestSelectorTransitionAndMoveHistory(t *testing.T) { + r, c, docs := registryFixture(t) + if active, err := ValidateRegistry(r, c, docs); err != nil || len(active) != 2 { + t.Fatalf("snapshot: %v", err) + } + id := r.Selectors[0].Snapshot[0] + old := r.Selectors[0].ContractID + next := "feature/v1" + r.Selectors[0].Exclusions = []string{id.ID} + r.Records = []Record{{id.ID, id.Path, id.Type, id.ContextRoot, next, c.Manifest.Contracts[next].Digest}} + r.History = append(r.History, Event{"transition", id.ID, id.Path, id.Path, old, next, []string{"review/1"}}) + raw, _ := Project(docs[id.Path].Raw, map[string]string{"flow_contract": next}) + docs[id.Path] = document(t, id.Path, string(raw)) + active, err := ValidateRegistry(r, c, docs) + if err != nil || active[id.ID].SelectorID != "" { + t.Fatalf("transition: %v", err) + } + moved := id.ContextRoot + "/renamed.md" + r.Records[0].Path = moved + r.History = append(r.History, Event{"move", id.ID, id.Path, moved, next, next, []string{}}) + docs[moved] = document(t, moved, string(raw)) + delete(docs, id.Path) + if active, err = ValidateRegistry(r, c, docs); err != nil || active[id.ID].Identity.Path != moved { + t.Fatalf("move: %v", err) + } + r.Selectors[0].Exclusions = []string{} + if _, err = ValidateRegistry(r, c, docs); err == nil { + t.Fatal("accepted selector plus record without exclusion") + } +} +func TestRegistryTamperingFails(t *testing.T) { + for _, name := range []string{"missing target", "removed id", "orphan", "changed type", "history", "selector id", "missing bundle"} { + t.Run(name, func(t *testing.T) { + r, c, docs := registryFixture(t) + id := r.Selectors[0].Snapshot[0] + switch name { + case "missing target": + delete(docs, id.Path) + case "removed id": + d := docs[id.Path] + delete(d.Fields, "document_id") + docs[id.Path] = d + case "orphan": + docs["memory-bank/features/FT-1/copy.md"] = docs[id.Path] + case "changed type": + d := docs[id.Path] + d.Fields["document_type"] = "adr" + docs[id.Path] = d + case "history": + r.History[0].ToPath = "memory-bank/features/FT-1/other.md" + case "selector id": + r.Selectors[0].ID = "sel-tampered" + case "missing bundle": + delete(c.Bundles, r.Selectors[0].ContractID) + } + if _, err := ValidateRegistry(r, c, docs); err == nil { + t.Fatal("accepted tampered state") + } + }) + } +} +func TestCanonicalRegistryAndBaseDocument(t *testing.T) { + r, c, docs := registryFixture(t) + base := document(t, "memory-bank/features/FT-3/brief.md", "---\nstatus: draft\ndocument_type: feature\n---\n# Base\n") + docs[base.Path] = base + active, err := ValidateRegistry(r, c, docs) + if err != nil || len(active) != 2 { + t.Fatalf("base joined snapshot: %v", err) + } + data, err := RegistryBytes(r) + if err != nil { + t.Fatal(err) + } + if _, err = ReadRegistry(data); err != nil { + t.Fatal(err) + } + if _, err = ReadRegistry(append([]byte(" "), data...)); err == nil { + t.Fatal("accepted noncanonical registry") + } +} diff --git a/internal/contracts/rules.go b/internal/contracts/rules.go new file mode 100644 index 0000000..c837ce5 --- /dev/null +++ b/internal/contracts/rules.go @@ -0,0 +1,204 @@ +package contracts + +import ( + "bytes" + "embed" + "errors" + "fmt" + "strings" +) + +// EngineArtifact is deliberately frozen with its implementation and corpus. New +// semantics require a new engine identifier and artifact, never edits in place. +// +//go:embed engines/governance-v1.json +var engineFiles embed.FS + +const EngineID = "governance/v1" + +func EngineArtifact() []byte { + b, err := engineFiles.ReadFile("engines/governance-v1.json") + if err != nil { + panic(err) + } + return b +} +func EngineDigest() string { return Digest(EngineArtifact()) } + +type Rules struct { + Fields map[string][]string `json:"fields,omitempty"` + Sections []string `json:"sections,omitempty"` + ActiveRequiresUpstream bool `json:"active_requires_upstream,omitempty"` + FeatureLifecycle bool `json:"feature_lifecycle,omitempty"` +} +type DNA struct { + SchemaVersion int `json:"schema_version"` + Rules Rules `json:"rules"` +} +type DocumentType struct { + SchemaVersion int `json:"schema_version"` + Type string `json:"type"` + Template string `json:"template"` + Rules Rules `json:"rules"` +} +type EngineRef struct { + ID string `json:"id"` + Digest string `json:"digest"` +} +type Bundle struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Type string `json:"type"` + Engine EngineRef `json:"engine"` + DNA Rules `json:"dna"` + Base Rules `json:"base"` + Extension Rules `json:"extension"` + Legacy bool `json:"legacy"` + TransitionEvidence bool `json:"transition_evidence"` +} +type Catalog struct { + DNA DNA + Types map[string]DocumentType + Bundles map[string]Bundle + Manifest Manifest + Files map[string][]byte +} + +func MergeRules(sets ...Rules) (Rules, error) { + out := Rules{Fields: map[string][]string{}, Sections: []string{}} + sections := map[string]bool{} + for _, r := range sets { + if !SortedSet(r.Sections) { + return out, errors.New("sections must be a sorted set") + } + for k, values := range r.Fields { + if strings.TrimSpace(k) == "" || !SortedSet(values) { + return out, errors.New("invalid field rule") + } + if prior, exists := out.Fields[k]; exists && len(prior) > 0 { + if len(values) == 0 { + return out, fmt.Errorf("field %s weakens its parent enum", k) + } + for _, v := range values { + if !Contains(prior, v) { + return out, fmt.Errorf("field %s widens its parent enum", k) + } + } + } + out.Fields[k] = append([]string{}, values...) + } + for _, section := range r.Sections { + sections[section] = true + } + out.ActiveRequiresUpstream = out.ActiveRequiresUpstream || r.ActiveRequiresUpstream + out.FeatureLifecycle = out.FeatureLifecycle || r.FeatureLifecycle + } + out.Sections = Keys(sections) + return out, nil +} +func Contains(values []string, s string) bool { + for _, v := range values { + if v == s { + return true + } + } + return false +} + +// LoadCatalog reads only installed definitions when selection is non-nil. A full +// source calls it with nil after validating the exhaustive inventory. +func LoadCatalog(m Manifest, files map[string][]byte, selection *Installation) (Catalog, error) { + c := Catalog{Manifest: m, Files: files, Types: map[string]DocumentType{}, Bundles: map[string]Bundle{}} + if err := Decode(files[m.DNAContract], &c.DNA); err != nil { + return c, fmt.Errorf("DNA contract: %w", err) + } + if c.DNA.SchemaVersion != 1 { + return c, errors.New("unsupported DNA schema") + } + if _, err := MergeRules(c.DNA.Rules); err != nil { + return c, err + } + if c.DNA.Rules.FeatureLifecycle { + return c, errors.New("DNA cannot contain feature lifecycle") + } + if selection != nil && !selection.Has("documents") { + return c, nil + } + for typ, p := range m.DocumentTypes { + var d DocumentType + if err := Decode(files[p], &d); err != nil { + return c, fmt.Errorf("base type %s: %w", typ, err) + } + if d.SchemaVersion != 1 || d.Type != typ { + return c, fmt.Errorf("incompatible base type %s", typ) + } + if err := m.requireFile(d.Template, "documents"); err != nil { + return c, err + } + if _, err := MergeRules(c.DNA.Rules, d.Rules); err != nil { + return c, err + } + if d.Rules.FeatureLifecycle { + return c, errors.New("base type cannot contain flow lifecycle") + } + template, exists := files[d.Template] + if !exists { + return c, fmt.Errorf("missing base template %s", d.Template) + } + doc, err := ParseDocument(d.Template, template) + if err != nil || !doc.HasFrontmatter { + return c, fmt.Errorf("invalid base template %s: %v", d.Template, err) + } + if doc.String("status") != "draft" || doc.String("document_type") != typ || doc.String("doc_kind") != typ || doc.Has("document_id") || doc.Has("flow_contract") { + return c, fmt.Errorf("invalid base template metadata %s", d.Template) + } + if bytes.Contains(doc.Body, []byte("\n---\n")) { + return c, fmt.Errorf("embedded frontmatter is unsupported in %s", d.Template) + } + c.Types[typ] = d + } + if selection != nil && !selection.Has("flows") { + return c, nil + } + for id, ref := range m.Contracts { + data, exists := files[ref.Path] + if !exists || Digest(data) != ref.Digest { + return c, fmt.Errorf("missing or changed bundle %s", id) + } + var b Bundle + if err := Decode(data, &b); err != nil { + return c, fmt.Errorf("bundle %s: %w", id, err) + } + if b.SchemaVersion != 1 || b.ID != id || !ValidContractID(id) || b.Engine.ID != EngineID || b.Engine.Digest != EngineDigest() { + return c, fmt.Errorf("unsupported bundle or engine %s", id) + } + if _, ok := c.Types[b.Type]; !ok { + return c, fmt.Errorf("unknown bundle type %s", b.Type) + } + r, err := MergeRules(b.DNA, b.Base, b.Extension) + if err != nil { + return c, err + } + if r.FeatureLifecycle && b.Type != "feature" { + return c, errors.New("feature lifecycle requires feature type") + } + if b.Legacy { + expected, err := LegacyBundle(id, b.Type) + actualBytes, _ := Canonical(b) + expectedBytes, _ := Canonical(expected) + if err != nil || !bytes.Equal(actualBytes, expectedBytes) { + return c, fmt.Errorf("legacy bundle %s differs from frozen classifier rules", id) + } + } + c.Bundles[id] = b + } + for _, compat := range m.LegacySources { + for typ, id := range compat.Contracts { + b, ok := c.Bundles[id] + if !ok || !b.Legacy || b.Type != typ { + return c, errors.New("incompatible legacy bundle mapping") + } + } + } + return c, nil +} diff --git a/internal/contracts/source_integration_test.go b/internal/contracts/source_integration_test.go new file mode 100644 index 0000000..a14b186 --- /dev/null +++ b/internal/contracts/source_integration_test.go @@ -0,0 +1,75 @@ +package contracts + +import ( + "io/fs" + "os" + "path/filepath" + "testing" +) + +// The template repository's CI supplies its exact candidate checkout. Ordinary +// unit tests stay hermetic and use their own fixtures. +func TestTemplateProducerConsumer(t *testing.T) { + root := os.Getenv("MEMORY_BANK_COMPONENT_SOURCE") + if root == "" { + t.Skip("set MEMORY_BANK_COMPONENT_SOURCE to the exact template checkout") + } + inventory := map[string][]byte{} + err := filepath.WalkDir(filepath.Join(root, "template"), func(p string, e fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if e.IsDir() { + return nil + } + rel, err := filepath.Rel(filepath.Join(root, "template"), p) + if err != nil { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + inventory[filepath.ToSlash(rel)] = data + return nil + }) + if err != nil { + t.Fatal(err) + } + manifest, err := ReadManifest(inventory[ManifestPath], inventory) + if err != nil { + t.Fatal(err) + } + catalog, err := LoadCatalog(manifest, inventory, nil) + if err != nil { + t.Fatal(err) + } + if len(catalog.Types) != 6 || len(catalog.Bundles) != 12 { + t.Fatalf("incomplete producer: %d types, %d bundles", len(catalog.Types), len(catalog.Bundles)) + } + for _, preset := range []string{"core", "docs", "full", "legacy"} { + t.Run(preset, func(t *testing.T) { + selected, err := manifest.Select(preset, nil, nil) + if err != nil { + t.Fatal(err) + } + subset := map[string][]byte{} + for p, entry := range manifest.Files { + if selected.Has(entry.Component) { + subset[p] = inventory[p] + } + } + declared, err := ReadManifest(subset[ManifestPath], nil) + if err != nil { + t.Fatal(err) + } + actual, err := LoadCatalog(declared, subset, &selected) + if err != nil { + t.Fatal(err) + } + if (len(actual.Types) > 0) != selected.Has("documents") || (len(actual.Bundles) > 0) != selected.Has("flows") { + t.Fatal("unselected definitions were loaded") + } + }) + } +} From d1f54a5f7729bac05526fcbf7eb9245d066727eb Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 05:00:09 +0300 Subject: [PATCH 05/13] fix: enforce frozen validation and projection boundaries --- internal/contracts/document.go | 6 ++++++ internal/contracts/engine.go | 30 ++++++++++++++++++++++------- internal/contracts/engine_test.go | 27 ++++++++++++++++++++++++++ internal/contracts/registry.go | 7 ++++++- internal/contracts/registry_test.go | 30 +++++++++++++++++++++++++++++ internal/contracts/rules.go | 22 ++++++++++++++++++++- 6 files changed, 113 insertions(+), 9 deletions(-) diff --git a/internal/contracts/document.go b/internal/contracts/document.go index 179683b..a7ad914 100644 --- a/internal/contracts/document.go +++ b/internal/contracts/document.go @@ -120,6 +120,12 @@ func Project(data []byte, updates map[string]string) ([]byte, error) { if k.Style != 0 || k.Column != 1 || v.Kind != yaml.ScalarNode || v.Tag != "!!str" || v.Anchor != "" || v.Line != k.Line || v.Style&(yaml.TaggedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { return nil, fmt.Errorf("projection %s requires a plain key and single-line untagged string", key) } + physicalLines := bytes.Split(d.Raw, []byte("\n")) + sourceLine := physicalLines[k.Line] // Node lines are relative to YAML after delimiter. + var oneLine map[string]any + if err := yaml.Unmarshal(sourceLine, &oneLine); err != nil || oneLine[key] != d.Fields[key] { + return nil, fmt.Errorf("projection %s spans physical lines", key) + } if d.String(key) == value { continue } diff --git a/internal/contracts/engine.go b/internal/contracts/engine.go index 42c5be2..2748fc9 100644 --- a/internal/contracts/engine.go +++ b/internal/contracts/engine.go @@ -61,16 +61,32 @@ func ValidateRules(d Document, r Rules, id Identity, context map[string]Document func ValidateBundle(d Document, b Bundle, id Identity, context map[string]Document) []Finding { if b.Legacy { out := legacyMetadata(d, id, id.Type == "feature", id.Type == "adr") - if id.Type == "feature" || id.Type == "research" || id.Type == "epic" { - for _, p := range Keys(context) { - other := context[p] - if p != d.Path && strings.HasPrefix(p, id.ContextRoot+"/") { - out = append(out, legacyMetadata(other, id, false, false)...) - } + validContext := map[string]Document{} + for _, p := range Keys(context) { + other := context[p] + if !strings.HasPrefix(p, id.ContextRoot+"/") { + continue + } + if p != d.Path && (id.Type == "feature" || id.Type == "research" || id.Type == "epic") { + out = append(out, legacyMetadata(other, id, false, false)...) } + fields, found, err := parseLegacyFrontmatter(other.Raw) + if err == nil && found { + other.Fields = fields + validContext[p] = other + } + } + fields, found, err := parseLegacyFrontmatter(d.Raw) + if err == nil && found { + d.Fields = fields + validContext[d.Path] = d } if id.Type == "feature" { - out = append(out, featureFindings(d, id, context, true)...) + if _, valid := validContext[d.Path]; valid { + out = append(out, featureFindings(d, id, validContext, true)...) + } else if len(validContext) > 0 { + out = append(out, finding(id, "", "lifecycle.feature_brief_missing", "lifecycle.feature_brief_missing")) + } } return SortFindings(out) } diff --git a/internal/contracts/engine_test.go b/internal/contracts/engine_test.go index bf35720..878710c 100644 --- a/internal/contracts/engine_test.go +++ b/internal/contracts/engine_test.go @@ -130,3 +130,30 @@ func TestLegacyDesignParserRetainsHistoricalCommentBehavior(t *testing.T) { t.Fatal("compatibility parser changed historical behavior") } } + +func TestProjectionRejectsMultilineScalarContinuation(t *testing.T) { + for _, raw := range []string{"---\ndocument_id: \"old\n continued\"\nstatus: draft\n---\n", "---\ndocument_id: old\n continued\nstatus: draft\n---\n"} { + if _, err := Project([]byte(raw), map[string]string{"document_id": "replacement"}); err == nil { + t.Fatalf("accepted multiline projection: %q", raw) + } + } +} +func TestLegacyMissingFrontmatterCompanionDoesNotActivateLifecycle(t *testing.T) { + p := "memory-bank/features/FT-1/brief.md" + d := document(t, p, "---\nstatus: draft\ndelivery_status: planned\n---\n# Brief\n") + companion := document(t, "memory-bank/features/FT-1/design.md", "# No frontmatter\n") + id := Identity{"doc-stable", p, "feature", "memory-bank/features/FT-1"} + bundle, _ := LegacyBundle("legacy/f1f04de/feature/v1", "feature") + got := ValidateBundle(d, bundle, id, map[string]Document{p: d, companion.Path: companion}) + if len(got) != 1 || got[0].Code != "governance.frontmatter_missing" || got[0].Subject != "design.md" { + t.Fatalf("legacy presence semantics changed: %v", got) + } +} +func TestEmbeddedMetadataCRLFAndHorizontalRule(t *testing.T) { + if !hasEmbeddedFrontmatter([]byte("# Example\r\n---\r\nstatus: draft\r\n---\r\n")) { + t.Fatal("missed CRLF embedded metadata") + } + if hasEmbeddedFrontmatter([]byte("# Title\n---\nJust a paragraph.\n---\n")) { + t.Fatal("horizontal rule mistaken for metadata") + } +} diff --git a/internal/contracts/registry.go b/internal/contracts/registry.go index 8c402b8..ba09061 100644 --- a/internal/contracts/registry.go +++ b/internal/contracts/registry.go @@ -119,7 +119,7 @@ func ValidateRegistry(r Registry, c Catalog, docs map[string]Document) (map[stri } previous = "" for _, sel := range r.Selectors { - if sel.ID <= previous || sel.ID != SelectorID(sel.SourceRef, sel.Type, sel.ContractID, sel.BundleDigest) || !SortedSet(sel.Exclusions) { + if len(sel.Snapshot) == 0 || sel.ID <= previous || sel.ID != SelectorID(sel.SourceRef, sel.Type, sel.ContractID, sel.BundleDigest) || !SortedSet(sel.Exclusions) { return nil, errors.New("invalid selector identity or ordering") } previous = sel.ID @@ -223,6 +223,11 @@ func ValidateRegistry(r Registry, c Catalog, docs map[string]Document) (map[stri return nil, errors.New("invalid move event") } case "transition": + oldBundle := c.Bundles[event.FromContract] + newBundle := c.Bundles[event.ToContract] + if (oldBundle.TransitionEvidence || newBundle.TransitionEvidence) && len(event.Evidence) == 0 { + return nil, errors.New("transition history lacks required evidence") + } if event.ToPath != prior.path || event.ToContract == prior.contract { return nil, errors.New("invalid transition event") } diff --git a/internal/contracts/registry_test.go b/internal/contracts/registry_test.go index 0b7ffe8..873839e 100644 --- a/internal/contracts/registry_test.go +++ b/internal/contracts/registry_test.go @@ -110,3 +110,33 @@ func TestCanonicalRegistryAndBaseDocument(t *testing.T) { t.Fatal("accepted noncanonical registry") } } + +func TestEmptySelectorRejected(t *testing.T) { + r, c, _ := registryFixture(t) + r.Selectors = r.Selectors[:1] + r.Selectors[0].Snapshot = []Identity{} + r.History = []Event{} + if _, err := ValidateRegistry(r, c, map[string]Document{}); err == nil { + t.Fatal("accepted empty selector group") + } +} +func TestTransitionReplayRequiresAvailableBundleEvidence(t *testing.T) { + r, c, docs := registryFixture(t) + id := r.Selectors[0].Snapshot[0] + next := "feature/v1" + bundle := c.Bundles[next] + bundle.TransitionEvidence = true + c.Bundles[next] = bundle + r.Selectors[0].Exclusions = []string{id.ID} + r.Records = []Record{{id.ID, id.Path, id.Type, id.ContextRoot, next, c.Manifest.Contracts[next].Digest}} + r.History = append(r.History, Event{"transition", id.ID, id.Path, id.Path, r.Selectors[0].ContractID, next, []string{}}) + raw, _ := Project(docs[id.Path].Raw, map[string]string{"flow_contract": next}) + docs[id.Path] = document(t, id.Path, string(raw)) + if _, err := ValidateRegistry(r, c, docs); err == nil { + t.Fatal("accepted transition without required evidence") + } + r.History[len(r.History)-1].Evidence = []string{"review/42"} + if _, err := ValidateRegistry(r, c, docs); err != nil { + t.Fatal(err) + } +} diff --git a/internal/contracts/rules.go b/internal/contracts/rules.go index c837ce5..2e2053a 100644 --- a/internal/contracts/rules.go +++ b/internal/contracts/rules.go @@ -152,7 +152,7 @@ func LoadCatalog(m Manifest, files map[string][]byte, selection *Installation) ( if doc.String("status") != "draft" || doc.String("document_type") != typ || doc.String("doc_kind") != typ || doc.Has("document_id") || doc.Has("flow_contract") { return c, fmt.Errorf("invalid base template metadata %s", d.Template) } - if bytes.Contains(doc.Body, []byte("\n---\n")) { + if hasEmbeddedFrontmatter(doc.Body) { return c, fmt.Errorf("embedded frontmatter is unsupported in %s", d.Template) } c.Types[typ] = d @@ -202,3 +202,23 @@ func LoadCatalog(m Manifest, files map[string][]byte, selection *Installation) ( } return c, nil } + +func hasEmbeddedFrontmatter(body []byte) bool { + lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") + for i, line := range lines { + if line != "---" { + continue + } + for j := i + 1; j < len(lines); j++ { + if lines[j] != "---" { + continue + } + embedded, err := ParseDocument("", []byte(strings.Join(lines[i:j+1], "\n")+"\n")) + if err == nil && (embedded.Has("status") || embedded.Has("doc_kind") || embedded.Has("document_type") || embedded.Has("document_id") || embedded.Has("flow_contract")) { + return true + } + break + } + } + return false +} From 1f13cbf5ca2e99461a34733eb424c8e0a5bb448a Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 05:07:49 +0300 Subject: [PATCH 06/13] fix: distinguish historical replay from transition authorization --- internal/contracts/engine_test.go | 8 ++++++++ internal/contracts/registry.go | 10 +++++++--- internal/contracts/registry_test.go | 21 +++++++++++++++++++++ internal/contracts/rules.go | 22 ++++++++++++++++++++-- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/internal/contracts/engine_test.go b/internal/contracts/engine_test.go index 878710c..5a046bd 100644 --- a/internal/contracts/engine_test.go +++ b/internal/contracts/engine_test.go @@ -157,3 +157,11 @@ func TestEmbeddedMetadataCRLFAndHorizontalRule(t *testing.T) { t.Fatal("horizontal rule mistaken for metadata") } } + +func TestEmbeddedLifecycleAndDependencyFieldsRejected(t *testing.T) { + for _, field := range []string{"delivery_status", "research_status", "decision_status", "derived_from", "purpose", "doc_function"} { + if !hasEmbeddedFrontmatter([]byte("# Body\n---\n" + field + ": value\n---\n")) { + t.Fatalf("missed %s", field) + } + } +} diff --git a/internal/contracts/registry.go b/internal/contracts/registry.go index ba09061..d8d5f03 100644 --- a/internal/contracts/registry.go +++ b/internal/contracts/registry.go @@ -76,6 +76,10 @@ func ReadRegistry(data []byte) (Registry, error) { return r, nil } +// ValidateRegistry checks a registry whose exact bytes have already been authenticated +// against installation.adoption_digest. CTR-01 deliberately does not require retaining +// inactive historical bundles; the trusted lock protects previously checked history. +// New writes must additionally call Catalog.ValidateTransition before appending an event. func ValidateRegistry(r Registry, c Catalog, docs map[string]Document) (map[string]Binding, error) { active := map[string]Binding{} all := map[string]Identity{} @@ -223,9 +227,9 @@ func ValidateRegistry(r Registry, c Catalog, docs map[string]Document) (map[stri return nil, errors.New("invalid move event") } case "transition": - oldBundle := c.Bundles[event.FromContract] - newBundle := c.Bundles[event.ToContract] - if (oldBundle.TransitionEvidence || newBundle.TransitionEvidence) && len(event.Evidence) == 0 { + oldBundle, oldAvailable := c.Bundles[event.FromContract] + newBundle, newAvailable := c.Bundles[event.ToContract] + if ((oldAvailable && oldBundle.TransitionEvidence) || (newAvailable && newBundle.TransitionEvidence)) && len(event.Evidence) == 0 { return nil, errors.New("transition history lacks required evidence") } if event.ToPath != prior.path || event.ToContract == prior.contract { diff --git a/internal/contracts/registry_test.go b/internal/contracts/registry_test.go index 873839e..ddfad32 100644 --- a/internal/contracts/registry_test.go +++ b/internal/contracts/registry_test.go @@ -140,3 +140,24 @@ func TestTransitionReplayRequiresAvailableBundleEvidence(t *testing.T) { t.Fatal(err) } } + +func TestRetiredHistoryIsReadableButCannotAuthorizeNewTransition(t *testing.T) { + old, c, docs := registryFixture(t) + id := old.Selectors[0].Snapshot[0] + current := "feature/v1" + retired := "retired/v1" + r := EmptyRegistry() + r.Records = []Record{{id.ID, id.Path, id.Type, id.ContextRoot, current, c.Manifest.Contracts[current].Digest}} + r.History = []Event{{"adopt", id.ID, id.Path, id.Path, "", retired, []string{}}, {"transition", id.ID, id.Path, id.Path, retired, current, []string{}}} + raw, _ := Project(docs[id.Path].Raw, map[string]string{"flow_contract": current}) + docs = map[string]Document{id.Path: document(t, id.Path, string(raw))} + if _, err := ValidateRegistry(r, c, docs); err != nil { + t.Fatalf("required an inactive historical bundle: %v", err) + } + if err := c.ValidateTransition(retired, current, id.Type, nil); err == nil { + t.Fatal("retired contract authorized a new transition") + } + if err := c.ValidateTransition(current, retired, id.Type, nil); err == nil { + t.Fatal("unknown target authorized a new transition") + } +} diff --git a/internal/contracts/rules.go b/internal/contracts/rules.go index 2e2053a..7e734d7 100644 --- a/internal/contracts/rules.go +++ b/internal/contracts/rules.go @@ -214,11 +214,29 @@ func hasEmbeddedFrontmatter(body []byte) bool { continue } embedded, err := ParseDocument("", []byte(strings.Join(lines[i:j+1], "\n")+"\n")) - if err == nil && (embedded.Has("status") || embedded.Has("doc_kind") || embedded.Has("document_type") || embedded.Has("document_id") || embedded.Has("flow_contract")) { - return true + if err == nil { + for _, key := range []string{"status", "title", "purpose", "doc_kind", "doc_function", "derived_from", "audience", "delivery_status", "research_status", "decision_status", "document_type", "document_id", "flow_contract", "template_for", "template_target_path", "canonical_for", "must_not_define"} { + if embedded.Has(key) { + return true + } + } } break } } return false } + +// ValidateTransition applies to a NEW operation. Both endpoints must be installed; +// unlike lock-authenticated history replay, this may not rely on retired contracts. +func (c Catalog) ValidateTransition(from, to, typ string, evidence []string) error { + old, oldOK := c.Bundles[from] + next, nextOK := c.Bundles[to] + if !oldOK || !nextOK || old.Type != typ || next.Type != typ || from == to { + return errors.New("transition requires two installed compatible contracts") + } + if !SortedSet(evidence) || ((old.TransitionEvidence || next.TransitionEvidence) && len(evidence) == 0) { + return errors.New("transition requires valid evidence references") + } + return nil +} From b85404eea0ecce0898299da90e3a1eda43f97002 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 05:30:42 +0300 Subject: [PATCH 07/13] feat: compose component installations and explicit document transactions --- internal/agentinstructions/block.go | 17 +- internal/cli/cli.go | 41 +- internal/cli/documents.go | 69 +++ internal/contracts/manifest.go | 4 + internal/contracts/renderer.go | 43 ++ internal/contracts/renderer_test.go | 38 ++ internal/doctor/doctor.go | 36 +- internal/ownership/component_file_unix.go | 55 +++ internal/ownership/component_file_windows.go | 22 + internal/ownership/component_recovery.go | 365 ++++++++++++++++ internal/ownership/component_recovery_test.go | 112 +++++ internal/ownership/component_state.go | 390 +++++++++++++++++ internal/ownership/components.go | 270 ++++++++++++ internal/ownership/components_test.go | 117 ++++++ internal/ownership/documents.go | 395 ++++++++++++++++++ internal/ownership/lock.go | 19 +- internal/ownership/source_format.go | 19 +- internal/ownership/types.go | 31 +- internal/ownership/update.go | 90 +++- 19 files changed, 2096 insertions(+), 37 deletions(-) create mode 100644 internal/cli/documents.go create mode 100644 internal/contracts/renderer.go create mode 100644 internal/contracts/renderer_test.go create mode 100644 internal/ownership/component_file_unix.go create mode 100644 internal/ownership/component_file_windows.go create mode 100644 internal/ownership/component_recovery.go create mode 100644 internal/ownership/component_recovery_test.go create mode 100644 internal/ownership/component_state.go create mode 100644 internal/ownership/components.go create mode 100644 internal/ownership/components_test.go create mode 100644 internal/ownership/documents.go diff --git a/internal/agentinstructions/block.go b/internal/agentinstructions/block.go index c1b3993..712286b 100644 --- a/internal/agentinstructions/block.go +++ b/internal/agentinstructions/block.go @@ -45,7 +45,10 @@ type markerLine struct { // BuildPlan returns a safe whole-file replacement while preserving every byte // outside the exact managed markers. A newly appended block is separated from // existing content by one blank line; no existing newline is rewritten. -func BuildPlan(original []byte) Plan { +func BuildPlan(original []byte) Plan { return BuildPlanWithBlock(original, CurrentBlock) } + +// BuildPlanWithBlock shares the exact marker boundary rules with component renderers. +func BuildPlanWithBlock(original, block []byte) Plan { rawStarts := bytes.Count(original, []byte(StartMarker)) rawEnds := bytes.Count(original, []byte(EndMarker)) upper := bytes.ToUpper(original) @@ -63,8 +66,8 @@ func BuildPlan(original []byte) Plan { } data = append(data, '\n') } - data = append(data, CurrentBlock...) - return Plan{Status: Missing, Data: data, Diff: blockDiff(nil, CurrentBlock)} + data = append(data, block...) + return Plan{Status: Missing, Data: data, Diff: blockDiff(nil, block)} } if len(starts) != 1 || len(ends) != 1 { return Plan{Status: Ambiguous} @@ -75,14 +78,14 @@ func BuildPlan(original []byte) Plan { start := starts[0].start end := ends[0].end existing := original[start:end] - if bytes.Equal(existing, CurrentBlock) { + if bytes.Equal(existing, block) { return Plan{Status: Current, Data: append([]byte(nil), original...)} } - data := make([]byte, 0, len(original)-len(existing)+len(CurrentBlock)) + data := make([]byte, 0, len(original)-len(existing)+len(block)) data = append(data, original[:start]...) - data = append(data, CurrentBlock...) + data = append(data, block...) data = append(data, original[end:]...) - return Plan{Status: Outdated, Data: data, Diff: blockDiff(existing, CurrentBlock)} + return Plan{Status: Outdated, Data: data, Diff: blockDiff(existing, block)} } // standaloneMarkers recognizes ownership boundaries only when the marker is diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3b75842..7361458 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -58,6 +58,8 @@ func Run(arguments []string, version string, stdout, stderr io.Writer) int { } switch arguments[0] { + case "document": + return runDocument(arguments[1:], stdout, stderr) case "capabilities": return runCapabilities(arguments[1:], version, stdout, stderr) case "analyze-graph": @@ -126,6 +128,7 @@ func printRootUsage(writer io.Writer) { fmt.Fprintln(writer, "Usage: memory-bank-cli [options]") fmt.Fprintln(writer) fmt.Fprintln(writer, "Commands:") + fmt.Fprintln(writer, " document Create, adopt, transition, or move a project document") fmt.Fprintln(writer, " capabilities Report supported source formats and required capabilities") fmt.Fprintln(writer, " analyze-graph Analyse typed execution-context handoff evidence") fmt.Fprintln(writer, " init Adopt or install a template and create its ownership lock") @@ -382,6 +385,12 @@ func runOwnership(arguments []string, command string, stdin io.Reader, stdinIsTe applyPlan := flags.String("apply-plan", "", "apply a reviewed versioned pull resolution plan from FILE") ask := flags.Bool("ask", false, "interactively resolve user-owned managed-file collisions") agentFile := flags.String("agent-file", "AGENTS.md", "single repository-relative agent instruction file to manage") + preset := flags.String("preset", "", "component preset: core, docs, full, legacy") + var adapters entrypointFlags + flags.Var(&adapters, "adapter", "add an optional adapter (repeatable)") + migrate := flags.Bool("migrate-components", false, "explicitly migrate a supported legacy installation") + migrationDigest := flags.String("migration-plan-digest", "", "exact reviewed migration preview digest") + migrationResolution := flags.String("migration-resolution", "", "legacy classification resolution JSON file") jsonOutput := addJSONOutputFlag(flags) if err := flags.Parse(arguments); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -442,7 +451,18 @@ func runOwnership(arguments []string, command string, stdin io.Reader, stdinIsTe options := ownership.Options{ RepoRoot: repoRoot, SourceRoot: sourceRoot, TemplateVersion: resolvedVersion, SourceRef: resolvedRef, DryRun: *dryRun, - AgentFile: *agentFile, + AgentFile: *agentFile, Preset: *preset, Adapters: adapters, MigrateComponents: *migrate, MigrationPlanDigest: *migrationDigest, + } + if *migrationResolution != "" { + options.MigrationResolution, err = os.ReadFile(*migrationResolution) + if err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + } + if command == "init" && (*migrate || *migrationDigest != "" || *migrationResolution != "") { + fmt.Fprintln(stderr, "migration flags require pull") + return exitUsage } if *planOutput != "" { plan, planErr := ownership.PlanPull(options) @@ -981,6 +1001,25 @@ func runLint(arguments []string, commandName, version string, stdout, stderr io. return exitFailure } + if scopeRoot == "memory-bank" { + handled, findings, nav, e := ownership.ValidateComponents(repoRoot, "") + if handled { + if e != nil { + report.Errors.Config = append(report.Errors.Config, lint.ConfigError{Message: e.Error()}) + report.ExitCode = 1 + } else { + if len(configuredEntrypoints) == 0 { + report = nav + report.RepoRoot = repoRoot + } + for _, f := range findings { + report.Errors.Config = append(report.Errors.Config, lint.ConfigError{Message: fmt.Sprintf("%s: %s (%s)", f.Code, f.RuleID, f.Subject)}) + report.ExitCode = 1 + } + } + } + } + if err := writeResult(stdout, *jsonOutput, report, func(writer io.Writer) { lint.PrintTextReport(writer, report) }); err != nil { diff --git a/internal/cli/documents.go b/internal/cli/documents.go new file mode 100644 index 0000000..aae943d --- /dev/null +++ b/internal/cli/documents.go @@ -0,0 +1,69 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + + "github.com/dapi/memory-bank-cli/internal/ownership" + "github.com/dapi/memory-bank-cli/internal/repository" +) + +func runDocument(arguments []string, stdout, stderr io.Writer) int { + if len(arguments) == 0 { + fmt.Fprintln(stderr, "Usage: memory-bank-cli document [options]") + return exitUsage + } + op := arguments[0] + switch op { + case "create", "adopt", "transition", "move": + default: + fmt.Fprintln(stderr, "unknown document operation") + return exitUsage + } + flags := flag.NewFlagSet("memory-bank-cli document "+op, flag.ContinueOnError) + flags.SetOutput(stderr) + root := addRepoRootFlag(flags) + o := ownership.DocumentOptions{Operation: op} + flags.StringVar(&o.Type, "type", "", "installed document type") + flags.StringVar(&o.Path, "path", "", "project Markdown path under memory-bank") + flags.StringVar(&o.To, "to", "", "move destination within the original context") + flags.StringVar(&o.ID, "id", "", "required stable identity for move") + flags.StringVar(&o.Contract, "contract", "", "installed flow contract to adopt") + flags.BoolVar(&o.LegacyFlow, "legacy-flow", false, "use the installation's pinned compatibility contract") + flags.BoolVar(&o.DryRun, "dry-run", false, "validate and preview without mutation") + var evidence entrypointFlags + flags.Var(&evidence, "evidence", "transition evidence reference (repeatable)") + jsonOutput := addJSONOutputFlag(flags) + if err := flags.Parse(arguments[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return exitSuccess + } + return exitUsage + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "unexpected document arguments") + return exitUsage + } + var err error + o.RepoRoot, err = repository.ResolveRoot(*root) + if err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + o.Evidence = evidence + report, err := ownership.DocumentOperation(o) + if err != nil { + if report.Applied { + _ = writeResult(stdout, *jsonOutput, report, func(w io.Writer) { printOwnershipReport(w, report) }) + } + fmt.Fprintln(stderr, err) + return exitFailure + } + if err = writeResult(stdout, *jsonOutput, report, func(w io.Writer) { printOwnershipReport(w, report) }); err != nil { + fmt.Fprintln(stderr, err) + return exitFailure + } + return exitSuccess +} diff --git a/internal/contracts/manifest.go b/internal/contracts/manifest.go index a2e9382..f0195b0 100644 --- a/internal/contracts/manifest.go +++ b/internal/contracts/manifest.go @@ -52,6 +52,7 @@ type Manifest struct { MigrationPaths map[string]PathMigration `json:"migration_paths,omitempty"` } type Installation struct { + RendererVersion *int `json:"renderer_version,omitempty"` Preset string `json:"preset"` Components []string `json:"components"` Adapters []string `json:"adapters"` @@ -323,6 +324,9 @@ func sorted(values []string) []string { // ValidateInstallation checks a persisted closure rather than treating it as new opt-in. func (m Manifest) ValidateInstallation(s Installation) error { + if s.RendererVersion != nil && *s.RendererVersion != 1 && *s.RendererVersion != 2 { + return errors.New("unsupported component renderer") + } if !ValidDigest(s.ManifestDigest) || !SortedSet(s.Components) || !SortedSet(s.Adapters) { return errors.New("invalid installation metadata") } diff --git a/internal/contracts/renderer.go b/internal/contracts/renderer.go new file mode 100644 index 0000000..8e8660d --- /dev/null +++ b/internal/contracts/renderer.go @@ -0,0 +1,43 @@ +package contracts + +import ( + "github.com/dapi/memory-bank-cli/internal/agentinstructions" + "strings" +) + +func ReadmeBlock(m Manifest, s Installation) []byte { + version := 1 + if s.RendererVersion != nil { + version = *s.RendererVersion + } + lines := []string{agentinstructions.StartMarker, "## Installed components", ""} + add := func(label, target, annotation string) { + line := "- [" + label + "](" + target + ")" + if version == 2 { + line += " — " + annotation + "." + } + lines = append(lines, line) + } + add("DNA", "dna/README.md", "governance baseline") + if s.Has("documents") { + add("Document types", "document-types/README.md", "base document contracts") + add("Templates", "templates/README.md", "project-owned draft templates") + for _, name := range []string{"product", "domain", "engineering", "ops", "adr", "prd", "use-cases", "features", "research", "epics"} { + if f, ok := m.Files["memory-bank/"+name+"/README.md"]; ok && s.Has(f.Component) { + add(name, name+"/README.md", "project documents") + } + } + } + if s.Has("flows") { + add("Flows", "flows/README.md", "optional process contracts") + } + return []byte(strings.Join(append(lines, agentinstructions.EndMarker), "\n") + "\n") +} +func AgentBlock(s Installation) []byte { + b := strings.Replace(string(agentinstructions.CurrentBlock), "BLOCK VERSION: 3", "BLOCK VERSION: 4", 1) + if !s.Has("flows") { + b = strings.Replace(b, "memory-bank/README.md, memory-bank/dna/README.md, and memory-bank/flows/routing.md.", "memory-bank/README.md and memory-bank/dna/README.md.", 1) + } + return []byte(b) +} +func CurrentRenderer() *int { v := 2; return &v } diff --git a/internal/contracts/renderer_test.go b/internal/contracts/renderer_test.go new file mode 100644 index 0000000..d418037 --- /dev/null +++ b/internal/contracts/renderer_test.go @@ -0,0 +1,38 @@ +package contracts + +import ( + "github.com/dapi/memory-bank-cli/internal/agentinstructions" + "strings" + "testing" +) + +func TestRendererVersionsAndOutsideBytes(t *testing.T) { + m := Manifest{Files: map[string]File{"memory-bank/features/README.md": {Component: "documents"}}} + s := Installation{Components: []string{"dna", "documents"}, RendererVersion: CurrentRenderer()} + block := ReadmeBlock(m, s) + if strings.Contains(string(block), "Flows") || !strings.Contains(string(block), " — project documents.") { + t.Fatal(string(block)) + } + original := []byte("owned\r\n\r\n" + string(block) + "after\r\n") + plan := agentinstructions.BuildPlanWithBlock(original, block) + if plan.Status != agentinstructions.Current { + t.Fatal(plan.Status) + } + drift := []byte(strings.Replace(string(original), " — project documents.", "", 1)) + if agentinstructions.BuildPlanWithBlock(drift, block).Status != agentinstructions.Outdated { + t.Fatal("v2 annotation drift accepted") + } + s.RendererVersion = nil + v1 := ReadmeBlock(m, s) + legacy := []byte("owned\r\n\r\n" + string(v1) + "after\r\n") + if agentinstructions.BuildPlanWithBlock(legacy, v1).Status != agentinstructions.Current { + t.Fatal("v1 rejected") + } + upgraded := agentinstructions.BuildPlanWithBlock(legacy, block).Data + if string(upgraded) != string(original) { + t.Fatal("renderer changed outside bytes") + } + if strings.Contains(string(AgentBlock(s)), "flows/routing") { + t.Fatal("Documents requires Flows") + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index c5b9204..016a4ae 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -11,6 +11,7 @@ import ( "sort" "strings" + "github.com/dapi/memory-bank-cli/internal/contracts" "github.com/dapi/memory-bank-cli/internal/lint" "github.com/dapi/memory-bank-cli/internal/ownership" "github.com/dapi/memory-bank-cli/internal/projection" @@ -70,8 +71,39 @@ func Run(options Options) (Report, error) { } report := Report{FormatVersion: ReportFormatVersion, Profile: profile, RepoRoot: repoRoot, Navigation: navigation, Findings: []Finding{}} report.addNavigationFindings() - report.checkIdentityAndDrift(options.AgentFile, scopeRoot) - report.checkGovernance(scopeRoot) + + handled := false + if profile == ProfileDownstream { + var componentErr error + var findings []contracts.Finding + var nav lint.Report + handled, findings, nav, componentErr = ownership.ValidateComponents(repoRoot, options.AgentFile) + if handled { + if componentErr != nil { + report.add(Finding{Code: "components.invalid", Severity: Error, Group: "components", Message: componentErr.Error()}) + } else { + report.Navigation = nav + report.Navigation.RepoRoot = repoRoot + report.Findings = []Finding{} + report.addNavigationFindings() + for _, f := range findings { + report.add(Finding{Code: f.Code, Severity: Error, Group: "governance", Subject: f.Subject, Message: f.RuleID}) + } + lock, _, _ := ownership.ReadLock(repoRoot) + report.TemplateIdentity = TemplateIdentity{SchemaVersion: lock.SchemaVersion, Version: lock.Template.Version, SourceRef: lock.Template.SourceRef} + } + } + } + if !handled { + report.checkIdentityAndDrift(options.AgentFile, scopeRoot) + report.checkGovernance(scopeRoot) + } + if profile == ProfileTemplate { + if err := ownership.ValidateComponentSource(repoRoot); err != nil { + report.add(Finding{Code: "components.source_invalid", Severity: Error, Group: "components", Message: err.Error()}) + } + } + report.checkCI() sort.SliceStable(report.Findings, func(i, j int) bool { left, right := report.Findings[i], report.Findings[j] diff --git a/internal/ownership/component_file_unix.go b/internal/ownership/component_file_unix.go new file mode 100644 index 0000000..e568faa --- /dev/null +++ b/internal/ownership/component_file_unix.go @@ -0,0 +1,55 @@ +//go:build darwin || linux + +package ownership + +import ( + "errors" + "golang.org/x/sys/unix" + "os" + "syscall" +) + +func checkOriginalComponentFile(info os.FileInfo) error { + if !info.Mode().IsRegular() || info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 { + return errors.New("component input must be a regular file without special mode bits") + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || st.Nlink != 1 { + return errors.New("component input has unsupported hard links or identity") + } + return nil +} + +func syncComponentFile(repo pinnedRepo, p string) error { + parent, leaf, err := openDestinationParent(repo, p, false, nil) + if err != nil { + return err + } + defer syscall.Close(parent) + fd, err := unix.Openat(parent, leaf, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer syscall.Close(fd) + return syscall.Fsync(fd) +} +func syncComponentDirectory(repo pinnedRepo, p string) error { + if p == "." { + return syncLocalDirectory(repo.root) + } + fd, _, err := openDestinationParent(repo, p+"/sync-placeholder", false, nil) + if err != nil { + return err + } + defer syscall.Close(fd) + return syscall.Fsync(fd) +} + +func chmodComponentDirectory(repo pinnedRepo, p string, mode os.FileMode) error { + fd, _, err := openDestinationParent(repo, p+"/chmod-placeholder", false, nil) + if err != nil { + return err + } + defer unix.Close(fd) + return unix.Fchmod(fd, uint32(mode.Perm())) +} diff --git a/internal/ownership/component_file_windows.go b/internal/ownership/component_file_windows.go new file mode 100644 index 0000000..65f9f5c --- /dev/null +++ b/internal/ownership/component_file_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package ownership + +import ( + "errors" + "os" +) + +func checkOriginalComponentFile(info os.FileInfo) error { + return errors.New("component mutations require Linux or macOS") +} +func syncComponentFile(repo pinnedRepo, p string) error { + return errors.New("component mutations require Linux or macOS") +} +func syncComponentDirectory(repo pinnedRepo, p string) error { + return errors.New("component mutations require Linux or macOS") +} + +func chmodComponentDirectory(repo pinnedRepo, p string, mode os.FileMode) error { + return errors.New("component mutations require Linux or macOS") +} diff --git a/internal/ownership/component_recovery.go b/internal/ownership/component_recovery.go new file mode 100644 index 0000000..2a88d8f --- /dev/null +++ b/internal/ownership/component_recovery.go @@ -0,0 +1,365 @@ +package ownership + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "reflect" + "regexp" + "strings" + + "github.com/dapi/memory-bank-cli/internal/contracts" +) + +type componentJournal struct { + SchemaVersion int `json:"schema_version"` + State string `json:"state"` + Before map[string]observation `json:"before"` + After map[string]observation `json:"after"` + Backups map[string]string `json:"backups"` + Directories map[string]directoryState `json:"directories"` +} + +func prepareComponentJournal(repo pinnedRepo, options Options, mutations []mutation, staged []stagedMutation, staging string) (*componentJournal, error) { + j := &componentJournal{1, "prepared", map[string]observation{}, map[string]observation{}, map[string]string{}, map[string]directoryState{}} + for p, o := range options.componentObservations { + j.Before[p] = o + j.After[p] = o + } + for i, item := range mutations { + if item.topology != nil { + return nil, errors.New("component file/directory topology replacement requires an explicit migration") + } + p := item.decision.Path + o, _, err := observeComponent(repo, p) + if err != nil { + return nil, err + } + if expected, ok := j.Before[p]; ok && !sameObservation(o, expected) { + return nil, fmt.Errorf("component input changed before journal: %s", p) + } + j.Before[p] = o + j.After[p] = observation{} + if item.decision.Action != Delete { + mode := staged[i].replacementInfo.Mode().Perm() + j.After[p] = observation{true, digest(item.data), gitMode(mode), fmt.Sprintf("%04o", mode)} + } + if o.Exists { + j.Backups[p] = fmt.Sprintf("old/%06d", i) + } + } + for _, p := range contracts.Keys(j.Before) { + actual, _, err := observeComponent(repo, p) + if err != nil { + return nil, err + } + if !sameObservation(actual, j.Before[p]) { + return nil, fmt.Errorf("input changed before journal: %s", p) + } + if actual.Exists { + if err = syncComponentFile(repo, p); err != nil { + return nil, err + } + } + for parent := path.Dir(p); parent != "."; parent = path.Dir(parent) { + if _, ok := j.Directories[parent]; ok { + continue + } + if err = checkComponentAncestors(repo, parent+"/journal-check"); err != nil { + return nil, err + } + info, e := os.Lstat(filepath.Join(repo.root, filepath.FromSlash(parent))) + state := directoryState{} + if e == nil { + if !info.IsDir() || info.Mode()&(os.ModeSymlink|os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 { + return nil, fmt.Errorf("unsafe component directory %s", parent) + } + state = directoryState{true, fmt.Sprintf("%04o", info.Mode().Perm()), true, fmt.Sprintf("%04o", info.Mode().Perm())} + } else if !errors.Is(e, os.ErrNotExist) { + return nil, e + } + j.Directories[parent] = state + } + } + for _, item := range mutations { + if item.decision.Action == Delete { + continue + } + for parent := path.Dir(item.decision.Path); parent != "."; parent = path.Dir(parent) { + state := j.Directories[parent] + if !state.BeforeExists { + state.AfterExists = true + state.AfterMode = "0755" + j.Directories[parent] = state + } + } + } + for _, item := range staged { + if item.replacement != "" { + if err := syncLocalRegular(item.replacement); err != nil { + return nil, err + } + } + } + if err := verifyComponentInventory(repo, j.Before); err != nil { + return nil, err + } + if err := writeComponentJournal(staging, j); err != nil { + return nil, err + } + if err := syncLocalDirectory(repo.root); err != nil { + return nil, err + } + return j, nil +} +func writeComponentJournal(staging string, j *componentJournal) error { + b, err := contracts.Canonical(j) + if err != nil { + return err + } + b = append(b, '\n') + temporary := filepath.Join(staging, "recovery.json.next") + f, err := os.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + _, writeErr := f.Write(b) + syncErr := f.Sync() + closeErr := f.Close() + if err = errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + if err = os.Rename(temporary, filepath.Join(staging, "recovery.json")); err != nil { + return err + } + return syncLocalDirectory(staging) +} +func commitComponentJournal(repo pinnedRepo, j *componentJournal, staging string) error { + if err := verifyComponentInventory(repo, j.After); err != nil { + return err + } + if err := verifyComponentDirectoryStates(repo, j, true); err != nil { + return err + } + for _, p := range contracts.Keys(j.After) { + o, _, err := observeComponent(repo, p) + if err != nil { + return err + } + if !sameObservation(o, j.After[p]) { + return fmt.Errorf("component after state changed: %s", p) + } + if o.Exists { + if err = syncComponentFile(repo, p); err != nil { + return err + } + } + } + for _, p := range contracts.Keys(j.Directories) { + s := j.Directories[p] + if s.AfterExists { + if err := syncComponentDirectory(repo, p); err != nil { + return err + } + } + } + if err := syncLocalDirectory(repo.root); err != nil { + return err + } + j.State = "committed" + return writeComponentJournal(staging, j) +} +func syncComponentParents(repo pinnedRepo, p, backupParent string) error { + if err := syncComponentDirectory(repo, path.Dir(p)); err != nil { + return err + } + return syncLocalDirectory(backupParent) +} +func syncLocalRegular(p string) error { + f, err := os.Open(p) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} +func syncLocalDirectory(p string) error { return syncLocalRegular(p) } + +var recoveryBackupPattern = regexp.MustCompile(`^old/[0-9]{6}$`) +var permissionPattern = regexp.MustCompile(`^0[0-7]{3}$`) + +func validateJournal(j componentJournal) error { + if j.SchemaVersion != 1 || (j.State != "prepared" && j.State != "committed") || len(j.Before) == 0 || !reflect.DeepEqual(contracts.Keys(j.Before), contracts.Keys(j.After)) { + return errors.New("unknown recovery journal contract") + } + if err := contracts.CheckPortable(contracts.Keys(j.Before)); err != nil { + return err + } + for p, o := range j.Before { + if !contracts.ValidPath(p) || strings.HasPrefix(p, ".memory-bank-update-") { + return errors.New("unsafe recovery target") + } + for _, o := range []observation{o, j.After[p]} { + if o.Exists { + if !contracts.ValidDigest(o.Digest) || !modePattern.MatchString(o.Mode) || !permissionPattern.MatchString(o.Permissions) { + return errors.New("invalid recovery observation") + } + var bits uint32 + fmt.Sscanf(o.Permissions, "%o", &bits) + if gitMode(os.FileMode(bits)) != o.Mode { + return errors.New("inconsistent recovery permissions") + } + } else if o.Digest != "" || o.Mode != "" || o.Permissions != "" { + return errors.New("invalid absent observation") + } + } + } + slots := map[string]bool{} + for p, slot := range j.Backups { + before, ok := j.Before[p] + if !ok || !before.Exists || sameObservation(before, j.After[p]) || !recoveryBackupPattern.MatchString(slot) || slots[slot] { + return errors.New("invalid recovery backup mapping") + } + slots[slot] = true + } + for p, d := range j.Directories { + if !contracts.ValidPath(p) || strings.HasPrefix(p, ".memory-bank-update-") { + return errors.New("unsafe recovery directory") + } + if d.BeforeExists != permissionPattern.MatchString(d.BeforeMode) || d.AfterExists != permissionPattern.MatchString(d.AfterMode) || (!d.BeforeExists && d.BeforeMode != "") || (!d.AfterExists && d.AfterMode != "") { + return errors.New("invalid recovery directory state") + } + } + return nil +} + +// Recovery only verifies a complete owner-restored before state, or a durably +// committed after state. It never replays changes into repository targets. +func checkComponentRecovery(repo pinnedRepo, cleanup bool) error { + entries, err := os.ReadDir(repo.root) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), ".memory-bank-update-") { + continue + } + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return errors.New("recovery_required: unsafe retained staging") + } + staging := filepath.Join(repo.root, entry.Name()) + journalPath := filepath.Join(staging, "recovery.json") + info, e := os.Lstat(journalPath) + if e != nil || !info.Mode().IsRegular() { + return fmt.Errorf("recovery_required: missing safe journal in %s", staging) + } + b, e := os.ReadFile(journalPath) + if e != nil { + return e + } + var j componentJournal + if e = contracts.Decode(b, &j); e != nil { + return fmt.Errorf("recovery_required: %w", e) + } + canonical, e := contracts.Canonical(j) + if e != nil || string(append(canonical, '\n')) != string(b) { + return errors.New("recovery_required: noncanonical journal") + } + if e = validateJournal(j); e != nil { + return fmt.Errorf("recovery_required: %w", e) + } + expected := j.Before + if j.State == "committed" { + expected = j.After + } + for _, p := range contracts.Keys(expected) { + o, _, e := observeComponent(repo, p) + if e != nil || !sameObservation(o, expected[p]) { + return fmt.Errorf("recovery_required: restore complete %s state for %s using %s", j.State, p, staging) + } + } + for _, p := range contracts.Keys(j.Directories) { + d := j.Directories[p] + exists, mode := d.BeforeExists, d.BeforeMode + if j.State == "committed" { + exists, mode = d.AfterExists, d.AfterMode + } + if e = checkComponentAncestors(repo, p+"/recovery-check"); e != nil { + return e + } + info, e := os.Lstat(filepath.Join(repo.root, filepath.FromSlash(p))) + if !exists && errors.Is(e, os.ErrNotExist) { + continue + } + if e != nil || !exists || !info.IsDir() || fmt.Sprintf("%04o", info.Mode().Perm()) != mode { + return fmt.Errorf("recovery_required: directory %s", p) + } + } + if j.State == "committed" { + handled, _, _, e := ValidateComponents(repo.root, "") + if !handled || e != nil { + return fmt.Errorf("recovery_required: committed integrity: %v", e) + } + } + if cleanup { + if _, e = inspectRepoRoot(repo.root, repo.info); e != nil { + return e + } + if e = os.RemoveAll(staging); e != nil { + return e + } + if e = syncLocalDirectory(repo.root); e != nil { + return e + } + } + } + return nil +} + +func verifyComponentDirectoryStates(repo pinnedRepo, j *componentJournal, after bool) error { + for _, p := range contracts.Keys(j.Directories) { + d := j.Directories[p] + exists, mode := d.BeforeExists, d.BeforeMode + if after { + exists, mode = d.AfterExists, d.AfterMode + } + if err := checkComponentAncestors(repo, p+"/directory-check"); err != nil { + return err + } + info, err := os.Lstat(filepath.Join(repo.root, filepath.FromSlash(p))) + if !exists && errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil || !exists || !info.IsDir() || info.Mode()&(os.ModeSymlink|os.ModeSetuid|os.ModeSetgid|os.ModeSticky) != 0 || fmt.Sprintf("%04o", info.Mode().Perm()) != mode { + return fmt.Errorf("component directory state changed: %s", p) + } + } + return nil +} +func verifyComponentInventory(repo pinnedRepo, expected map[string]observation) error { + return filepath.WalkDir(filepath.Join(repo.root, "memory-bank"), func(full string, e os.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) && full == filepath.Join(repo.root, "memory-bank") { + return nil + } + if err != nil { + return err + } + rel, _ := filepath.Rel(repo.root, full) + p := filepath.ToSlash(rel) + if e.IsDir() { + if p == "memory-bank/.repo" { + return filepath.SkipDir + } + return nil + } + if e.Type()&os.ModeSymlink != 0 || !e.Type().IsRegular() { + return fmt.Errorf("unsafe concurrent component entry: %s", p) + } + if o, ok := expected[p]; !ok || !o.Exists { + return fmt.Errorf("unobserved component file appeared: %s", p) + } + return nil + }) +} diff --git a/internal/ownership/component_recovery_test.go b/internal/ownership/component_recovery_test.go new file mode 100644 index 0000000..6f49ae5 --- /dev/null +++ b/internal/ownership/component_recovery_test.go @@ -0,0 +1,112 @@ +package ownership + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestComponentRecoveryRequiresCompleteRestoration(t *testing.T) { + if !componentHost() { + t.Skip("POSIX component writer") + } + root := t.TempDir() + first := "memory-bank/a.md" + second := "memory-bank/b.md" + write(t, root, first, "before-a") + write(t, root, second, "before-b") + repo, err := pinRepoRoot(root) + if err != nil { + t.Fatal(err) + } + before := map[string]observation{} + for _, p := range []string{first, second} { + o, _, e := observeComponent(repo, p) + if e != nil { + t.Fatal(e) + } + before[p] = o + } + mutations := []mutation{{decision: Decision{Path: first, Action: UpdateFile}, data: []byte("after-a"), expectedExists: true}, {decision: Decision{Path: second, Action: UpdateFile}, data: []byte("after-b"), expectedExists: true}} + calls := 0 + options := Options{RepoRoot: root, componentTransaction: true, componentObservations: before, BeforeMutation: func(Decision) error { + calls++ + if calls == 2 { + return errors.New("interrupt") + } + return nil + }} + ops := transactionOps{writeFile: os.WriteFile, rename: os.Rename, link: func(from, to string) error { + if filepath.Base(filepath.Dir(from)) == "old" { + return errors.New("rollback blocked") + } + return os.Link(from, to) + }} + err = applyAtomicallyPinnedWithOps(options, mutations, repo, ops) + if err == nil || !strings.Contains(err.Error(), "rollback incomplete") { + t.Fatalf("expected retained failure: %v", err) + } + staging, _ := filepath.Glob(filepath.Join(root, ".memory-bank-update-*")) + if len(staging) != 1 { + t.Fatal(staging) + } + if err = checkComponentRecovery(repo, true); err == nil { + t.Fatal("mixed state allowed") + } + // Restore the original using the journal's numbered backup, without replay. + b, err := os.ReadFile(filepath.Join(staging[0], "old", "000000")) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(root, first), b, 0644); err != nil { + t.Fatal(err) + } + if err = os.Chmod(filepath.Join(root, first), 0600); err != nil { + t.Fatal(err) + } + if err = checkComponentRecovery(repo, true); err == nil { + t.Fatal("permission-only partial restoration allowed") + } + if err = os.Chmod(filepath.Join(root, first), 0644); err != nil { + t.Fatal(err) + } + if err = checkComponentRecovery(repo, true); err != nil { + t.Fatal(err) + } + if err = checkComponentRecovery(repo, true); err != nil { + t.Fatal(err) + } + assertNoTransactionStaging(t, root) +} +func TestComponentRejectsHardLinksAndPortableAliases(t *testing.T) { + if !componentHost() { + t.Skip("POSIX") + } + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("safe"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "memory-bank"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Link(outside, filepath.Join(root, "memory-bank", "target.md")); err != nil { + t.Fatal(err) + } + repo, err := pinRepoRoot(root) + if err != nil { + t.Fatal(err) + } + if _, _, err = observeComponent(repo, "memory-bank/target.md"); err == nil { + t.Fatal("external hardlink accepted") + } + if err = os.Remove(filepath.Join(root, "memory-bank", "target.md")); err != nil { + t.Fatal(err) + } + write(t, root, "memory-bank/Upper.md", "safe") + if _, _, err = observeComponent(repo, "memory-bank/upper.md"); err == nil { + t.Fatal("case alias accepted") + } +} diff --git a/internal/ownership/component_state.go b/internal/ownership/component_state.go new file mode 100644 index 0000000..bc5b41b --- /dev/null +++ b/internal/ownership/component_state.go @@ -0,0 +1,390 @@ +package ownership + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "reflect" + "runtime" + "strings" + + "github.com/dapi/memory-bank-cli/internal/agentinstructions" + "github.com/dapi/memory-bank-cli/internal/contracts" + "github.com/dapi/memory-bank-cli/internal/lint" +) + +type observation struct { + Exists bool `json:"exists"` + Digest string `json:"digest"` + Mode string `json:"mode"` + Permissions string `json:"permissions"` +} +type directoryState struct { + BeforeExists bool `json:"before_exists"` + BeforeMode string `json:"before_mode"` + AfterExists bool `json:"after_exists"` + AfterMode string `json:"after_mode"` +} +type componentTree struct { + files map[string][]byte + observed map[string]observation +} +type componentState struct { + manifest contracts.Manifest + catalog contracts.Catalog + registry contracts.Registry + bindings map[string]contracts.Binding + docs map[string]contracts.Document + findings []contracts.Finding +} + +func componentHost() bool { return runtime.GOOS == "darwin" || runtime.GOOS == "linux" } + +// Observe through pinned, no-follow handles, including exact permission bits. +func observeComponent(repo pinnedRepo, p string) (observation, []byte, error) { + if !contracts.ValidPath(p) { + return observation{}, nil, fmt.Errorf("unsafe component path %s", p) + } + if err := checkComponentAncestors(repo, p); err != nil { + return observation{}, nil, err + } + _, info, exists, err := inspectDestination(repo, p) + if err != nil { + return observation{}, nil, err + } + if !exists { + return observation{}, nil, nil + } + readInfo, data, err := secureReadDestination(repo, p) + if err != nil { + return observation{}, nil, err + } + if !os.SameFile(info, readInfo) { + return observation{}, nil, fmt.Errorf("%s changed while reading", p) + } + if err := checkOriginalComponentFile(readInfo); err != nil { + return observation{}, nil, fmt.Errorf("%s: %w", p, err) + } + return observation{true, digest(data), gitMode(readInfo.Mode().Perm()), fmt.Sprintf("%04o", readInfo.Mode().Perm())}, data, nil +} + +// Check each directory's actual spelling and siblings before resolving a target. +// This catches aliases even on case-insensitive or normalization-insensitive hosts. +func checkComponentAncestors(repo pinnedRepo, p string) error { + parts := strings.Split(p, "/") + current := repo.root + for i, part := range parts { + entries, err := os.ReadDir(current) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + names := []string{part} + found := false + for _, e := range entries { + if e.Name() == part { + found = true + continue + } + names = append(names, e.Name()) + } + // Only compare the target's collision key; unrelated nonportable entries do + // not prevent work in another subtree. + key := contracts.PortableKey(part) + for _, name := range names[1:] { + if contracts.PortableKey(name) == key { + return fmt.Errorf("portable path alias: %s / %s", part, name) + } + } + if !found { + return nil + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink in component path %s", p) + } + if i < len(parts)-1 && !info.IsDir() { + return fmt.Errorf("non-directory ancestor of %s", p) + } + } + return nil +} + +func readComponentTree(repo pinnedRepo, lock Lock, extra []string) (componentTree, error) { + t := componentTree{map[string][]byte{}, map[string]observation{}} + paths := map[string]bool{LockFileName: true, contracts.RegistryPath: true, "AGENTS.md": true} + for p := range lock.Files { + paths[p] = true + } + for _, p := range extra { + if p != "" { + paths[p] = true + } + } + root := filepath.Join(repo.root, "memory-bank") + err := filepath.WalkDir(root, func(full string, e fs.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) && full == root { + return nil + } + if err != nil { + return err + } + rel, _ := filepath.Rel(repo.root, full) + p := filepath.ToSlash(rel) + if e.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink in component tree %s", p) + } + if e.IsDir() { + if p == "memory-bank/.repo" { + return filepath.SkipDir + } + return nil + } + if !e.Type().IsRegular() { + return fmt.Errorf("non-regular component file %s", p) + } + paths[p] = true + return nil + }) + if err != nil { + return t, err + } + if err := contracts.CheckPortable(contracts.Keys(paths)); err != nil { + return t, err + } + for _, p := range contracts.Keys(paths) { + o, b, err := observeComponent(repo, p) + if err != nil { + return t, err + } + t.observed[p] = o + if o.Exists { + t.files[p] = b + } + } + return t, nil +} + +func decodeComponentState(files map[string][]byte, lock Lock, agentFile string, checkBlocks bool) (componentState, error) { + s := componentState{} + if lock.SchemaVersion != 2 || lock.Installation == nil { + return s, errors.New("component installation requires schema-2 lock") + } + installation := *lock.Installation + if digest(files[contracts.ManifestPath]) != installation.ManifestDigest { + return s, errors.New("installed component manifest digest mismatch") + } + m, err := contracts.ReadManifest(files[contracts.ManifestPath], nil) + if err != nil { + return s, err + } + s.manifest = m + if err = m.ValidateInstallation(installation); err != nil { + return s, err + } + selected := map[string][]byte{} + for p, f := range m.Files { + if !installation.Has(f.Component) { + continue + } + b, exists := files[p] + record, tracked := lock.Files[p] + if !exists || !tracked { + return s, fmt.Errorf("selected payload missing: %s", p) + } + if f.Ownership == "managed" { + if p == "memory-bank/README.md" { + if record.Ownership != Generated { + return s, errors.New("root README must be generated") + } + } else if record.Ownership != Managed || record.PayloadDigest != digest(b) { + return s, fmt.Errorf("managed contract drift: %s", p) + } + } + selected[p] = b + } + s.catalog, err = contracts.LoadCatalog(m, selected, &installation) + if err != nil { + return s, err + } + s.docs = map[string]contracts.Document{} + for _, p := range contracts.Keys(files) { + if !contracts.DocumentPath(p) { + continue + } + if f, ok := lock.Files[p]; ok && (f.Ownership == Managed || f.Ownership == Generated) { + continue + } + d, e := contracts.ParseDocument(p, files[p]) + if e != nil { + return s, fmt.Errorf("%s: %w", p, e) + } + s.docs[p] = d + } + s.registry = contracts.EmptyRegistry() + if installation.Has("flows") { + b, ok := files[contracts.RegistryPath] + if !ok || digest(b) != installation.AdoptionDigest { + return s, errors.New("missing or corrupt adoption registry") + } + s.registry, err = contracts.ReadRegistry(b) + if err != nil { + return s, err + } + } else if _, ok := files[contracts.RegistryPath]; ok { + return s, errors.New("registry exists without Flows") + } + s.bindings, err = contracts.ValidateRegistry(s.registry, s.catalog, s.docs) + if err != nil { + return s, err + } + adopted := map[string]bool{} + for _, id := range contracts.Keys(s.bindings) { + binding := s.bindings[id] + adopted[binding.Identity.Path] = true + s.findings = append(s.findings, contracts.ValidateBundle(s.docs[binding.Identity.Path], s.catalog.Bundles[binding.ContractID], binding.Identity, s.docs)...) + } + for _, p := range contracts.Keys(s.docs) { + d := s.docs[p] + if adopted[p] { + continue + } + rules := s.catalog.DNA.Rules + if d.Has("document_type") { + typ, ok := s.catalog.Types[d.String("document_type")] + if !ok { + return s, fmt.Errorf("unknown document type: %s", p) + } + rules, err = contracts.MergeRules(rules, typ.Rules) + if err != nil { + return s, err + } + } + s.findings = append(s.findings, contracts.ValidateRules(d, rules, contracts.Identity{Path: p, ContextRoot: path.Dir(p)}, s.docs)...) + } + contracts.SortFindings(s.findings) + if checkBlocks { + if agentinstructions.BuildPlanWithBlock(files["memory-bank/README.md"], contracts.ReadmeBlock(m, installation)).Status != agentinstructions.Current { + return s, errors.New("generated README block drift") + } + if agentFile == "" { + agentFile = agentinstructions.DefaultTarget + } + if agentinstructions.BuildPlanWithBlock(files[agentFile], contracts.AgentBlock(installation)).Status != agentinstructions.Current { + return s, errors.New("generated AGENTS block drift") + } + } + return s, nil +} + +// Navigation is audited in a disposable read-only view of the prospective data. +// It never uses that view to perform actual repository mutations. +func componentNavigation(files map[string][]byte, m contracts.Manifest, s contracts.Installation) (lint.Report, error) { + view, err := os.MkdirTemp("", "memory-bank-component-audit-") + if err != nil { + return lint.Report{}, err + } + defer os.RemoveAll(view) + for p, b := range files { + if !contracts.ValidPath(p) { + return lint.Report{}, fmt.Errorf("unsafe audit path %s", p) + } + if p == "memory-bank/README.md" { + v := s + v.RendererVersion = contracts.CurrentRenderer() + plan := agentinstructions.BuildPlanWithBlock(b, contracts.ReadmeBlock(m, v)) + if plan.Status == agentinstructions.Ambiguous { + return lint.Report{}, errors.New("ambiguous README") + } + b = plan.Data + } + target := filepath.Join(view, filepath.FromSlash(p)) + if err = os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return lint.Report{}, err + } + if err = os.WriteFile(target, b, 0600); err != nil { + return lint.Report{}, err + } + } + return lint.Run(lint.Options{RepoRoot: view, ScopeRoot: "memory-bank", MaxDepth: 3}) +} + +// ValidateComponents is shared by doctor and lint. A legacy installation returns +// handled=false; a component marker without its lock is a corruption error. +func ValidateComponents(root, agent string) (handled bool, findings []contracts.Finding, navigation lint.Report, err error) { + repo, e := pinRepoRoot(root) + if e != nil { + return false, nil, navigation, e + } + lock, exists, _, e := readLockSnapshot(repo) + if e != nil { + return true, nil, navigation, e + } + if !exists || lock.SchemaVersion != 2 { + _, _, marker, e := inspectDestination(repo, contracts.ManifestPath) + if e != nil { + return true, nil, navigation, e + } + if marker { + return true, nil, navigation, errors.New("component marker requires schema-2 lock") + } + return false, nil, navigation, nil + } + tree, e := readComponentTree(repo, lock, []string{agent}) + if e != nil { + return true, nil, navigation, e + } + for p, f := range lock.Files { + if f.Ownership == Managed && tree.observed[p].Mode != f.PayloadMode { + return true, nil, navigation, fmt.Errorf("managed mode drift: %s", p) + } + } + state, e := decodeComponentState(tree.files, lock, agent, true) + if e != nil { + return true, nil, navigation, e + } + nav, e := componentNavigation(tree.files, state.manifest, *lock.Installation) + return true, state.findings, nav, e +} + +func sameObservation(a, b observation) bool { return reflect.DeepEqual(a, b) } +func sameBytes(a, b []byte) bool { return bytes.Equal(a, b) } + +// ValidateComponentSource checks the complete local producer inventory. No +// downstream lock is expected when the explicit template profile is selected. +func ValidateComponentSource(root string) error { + source, err := pinSourceRoot(root) + if err != nil { + return err + } + marker := filepath.Join(root, "template", "memory-bank", "components.json") + if _, err = os.Lstat(marker); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + files, err := readSource(source) + if err != nil { + return err + } + inventory := map[string][]byte{} + for p, f := range files { + inventory[p] = f.data + } + m, err := contracts.ReadManifest(inventory[contracts.ManifestPath], inventory) + if err != nil { + return err + } + _, err = contracts.LoadCatalog(m, inventory, nil) + return err +} diff --git a/internal/ownership/components.go b/internal/ownership/components.go new file mode 100644 index 0000000..a219f8d --- /dev/null +++ b/internal/ownership/components.go @@ -0,0 +1,270 @@ +package ownership + +import ( + "errors" + "fmt" + "reflect" + "strings" + "time" + + "github.com/dapi/memory-bank-cli/internal/agentinstructions" + "github.com/dapi/memory-bank-cli/internal/contracts" +) + +type componentPlan struct { + tree componentTree + source map[string]payload + mutations []mutation + report Report + next Lock + manifest contracts.Manifest +} + +func prepareComponents(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest string, source map[string]payload) (componentPlan, error) { + p := componentPlan{source: source, report: Report{FormatVersion: ReportFormatVersion, DryRun: options.DryRun, Decisions: []Decision{}}} + if !componentHost() { + return p, errors.New("component mutations require Linux or macOS") + } + if options.SkipAgentInstructions || (options.AgentFile != "" && options.AgentFile != "AGENTS.md") { + return p, errors.New("component installation requires the canonical AGENTS.md block") + } + if err := checkComponentRecovery(repo, !options.DryRun); err != nil { + return p, err + } + inventory := map[string][]byte{} + for name, f := range source { + inventory[name] = f.data + } + m, err := contracts.ReadManifest(inventory[contracts.ManifestPath], inventory) + if err != nil { + return p, err + } + p.manifest = m + if _, err = contracts.LoadCatalog(m, inventory, nil); err != nil { + return p, err + } + // Migration has its own explicit consent and exact-observation preparation. + if hasLock && old.SchemaVersion != 2 { + return p, errors.New("legacy installation requires reviewed component migration") + } + if options.MigrateComponents || options.MigrationPlanDigest != "" || len(options.MigrationResolution) > 0 { + return p, errors.New("migration flags apply only to a legacy installation") + } + selection, err := m.Select(options.Preset, options.Adapters, old.Installation) + if err != nil { + return p, err + } + p.tree, err = readComponentTree(repo, old, contracts.Keys(source)) + if err != nil { + return p, err + } + if hasLock { + state, e := decodeComponentState(p.tree.files, old, "AGENTS.md", true) + if e != nil { + return p, e + } + if len(state.findings) > 0 { + return p, fmt.Errorf("installed document validation failed: %v", state.findings) + } + for name, f := range old.Files { + if f.Ownership == Managed && p.tree.observed[name].Mode != f.PayloadMode { + return p, fmt.Errorf("managed mode drift: %s", name) + } + } + } + selection.RendererVersion = contracts.CurrentRenderer() + selection.ManifestDigest = digest(inventory[contracts.ManifestPath]) + selected := map[string]payload{} + for name, f := range source { + declaration := m.Files[name] + if selection.Has(declaration.Component) { + f.class = Class(declaration.Ownership) + selected[name] = f + } + } + original := p.tree.files["memory-bank/README.md"] + if original == nil { + original = selected["memory-bank/README.md"].data + } + readme := agentinstructions.BuildPlanWithBlock(original, contracts.ReadmeBlock(m, selection)) + if readme.Status == agentinstructions.Ambiguous { + return p, errors.New("ambiguous root README markers") + } + rawReadme := selected["memory-bank/README.md"] + composed := rawReadme + composed.class = Generated + composed.data = readme.Data + composed.digest = digest(readme.Data) + selected["memory-bank/README.md"] = composed + // Existing managed assets must never become adapted through implicit drift. + if !hasLock { + for name, f := range selected { + if f.class == Managed { + if o := p.tree.observed[name]; o.Exists && (o.Digest != f.digest || o.Mode != f.mode) { + return p, fmt.Errorf("unmanaged file blocks component asset %s", name) + } + } + } + } + p.mutations, p.report.Decisions, p.next, err = buildPlan(repo, selected, old, hasLock, options.UserOwnedResolutions, options.AdaptedResolutions, options.DetachUserOwnedRemovals) + if err != nil { + return p, err + } + for _, d := range p.report.Decisions { + if d.Action == Conflict { + p.report.ConflictCount++ + } + } + if p.report.ConflictCount > 0 { + return p, nil + } + record := p.next.Files["memory-bank/README.md"] + record.Ownership = Generated + record.BaseDigest = rawReadme.digest + record.BaseMode = rawReadme.mode + p.next.Files["memory-bank/README.md"] = record + agent := agentinstructions.BuildPlanWithBlock(p.tree.files["AGENTS.md"], contracts.AgentBlock(selection)) + if agent.Status == agentinstructions.Ambiguous { + return p, errors.New("ambiguous AGENTS markers") + } + if agent.Status != agentinstructions.Current { + p.add("AGENTS.md", agent.Data, Managed, "compose component routing") + } + if selection.Has("flows") && (old.Installation == nil || !old.Installation.Has("flows")) { + if p.tree.observed[contracts.RegistryPath].Exists { + return p, errors.New("untracked adoption registry blocks initialization") + } + registryBytes, e := contracts.RegistryBytes(contracts.EmptyRegistry()) + if e != nil { + return p, e + } + selection.AdoptionDigest = digest(registryBytes) + p.add(contracts.RegistryPath, registryBytes, Generated, "initialize explicit adoption registry") + } + p.next.SchemaVersion = 2 + p.next.Installation = &selection + p.next.Template = Template{options.TemplateVersion, options.SourceRef} + // A true no-op keeps timestamp and lock bytes stable. + p.next.LastUpdate = old.LastUpdate + changed := !hasLock || !reflect.DeepEqual(old, p.next) || len(p.mutations) > 0 + if changed { + now := time.Now + if options.Now != nil { + now = options.Now + } + p.next.LastUpdate = UpdateRecord{options.TemplateVersion, now().UTC()} + } + future := p.future() + state, err := decodeComponentState(future, p.next, "AGENTS.md", true) + if err != nil { + return p, err + } + if len(state.findings) > 0 { + return p, fmt.Errorf("prospective document validation failed: %v", state.findings) + } + nav, err := componentNavigation(future, m, selection) + if err != nil { + return p, err + } + if nav.ExitCode != 0 { + return p, fmt.Errorf("prospective navigation invalid: %+v", nav.Errors) + } + if changed { + b, e := marshalLock(p.next) + if e != nil { + return p, e + } + p.add(LockFileName, b, Generated, "record component transaction") + } + return p, nil +} + +func (p *componentPlan) add(name string, data []byte, class Class, reason string) { + observed := p.tree.observed[name] + action := Create + if observed.Exists { + action = UpdateFile + } + if observed.Exists && observed.Digest == digest(data) { + return + } + d := Decision{Path: name, Action: action, Ownership: class, Reason: reason} + mode := fileMode("100644") + if observed.Exists { + _, _ = fmt.Sscanf(observed.Permissions, "%o", &mode) + } + p.mutations = append(p.mutations, mutation{decision: d, data: data, mode: mode, modeSet: true, expectedExists: observed.Exists, expectedDigest: observed.Digest, expectedMode: observed.Mode}) + p.report.Decisions = append(p.report.Decisions, d) +} +func (p componentPlan) future() map[string][]byte { + files := map[string][]byte{} + for name, b := range p.tree.files { + files[name] = b + } + for _, item := range p.mutations { + if item.decision.Action == Delete { + delete(files, item.decision.Path) + } else { + files[item.decision.Path] = item.data + } + } + return files +} +func runComponents(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest string, source map[string]payload) (Report, error) { + plan, err := prepareComponents(options, old, hasLock, repo, lockDigest, source) + if err != nil { + return Report{}, err + } + return applyComponentPlan(options, repo, plan) +} + +func applyComponentPlan(options Options, repo pinnedRepo, plan componentPlan) (Report, error) { + var err error + if plan.report.ConflictCount > 0 || options.DryRun || len(plan.mutations) == 0 { + return plan.report, nil + } + options.componentTransaction = true + options.componentObservations = plan.tree.observed + for i := range plan.mutations { + plan.mutations[i].expectedPermissions = plan.tree.observed[plan.mutations[i].decision.Path].Permissions + } + // Until staging starts, every observed input must retain its full before state. + for name, expected := range plan.tree.observed { + actual, _, e := observeComponent(repo, name) + if e != nil { + return Report{}, e + } + if !sameObservation(actual, expected) { + return Report{}, fmt.Errorf("component input changed: %s", name) + } + } + // Bind unmodified read inputs to lock-last, in addition to the writer's per-file checks. + mutated := map[string]bool{} + for _, item := range plan.mutations { + mutated[item.decision.Path] = true + } + last := len(plan.mutations) - 1 + for _, name := range contracts.Keys(plan.tree.observed) { + o := plan.tree.observed[name] + if !mutated[name] { + plan.mutations[last].preconditions = append(plan.mutations[last].preconditions, destinationPrecondition{path: name, checkExistence: true, exists: o.Exists, digest: o.Digest, mode: o.Mode, permissions: o.Permissions}) + } + } + if err = applyAtomicallyPinned(options, plan.mutations, repo); err != nil { + var committed *committedError + plan.report.Applied = errors.As(err, &committed) + return plan.report, err + } + plan.report.Applied = true + return plan.report, nil +} +func componentFlags(options Options) bool { + return options.Preset != "" || len(options.Adapters) > 0 || options.MigrateComponents || options.MigrationPlanDigest != "" || len(options.MigrationResolution) > 0 +} +func hasComponentSource(source map[string]payload) bool { + _, ok := source[contracts.ManifestPath] + return ok +} +func reservedComponentPath(p string) bool { + return strings.HasPrefix(p, ".memory-bank-update-") || p == contracts.RegistryPath || p == LockFileName +} diff --git a/internal/ownership/components_test.go b/internal/ownership/components_test.go new file mode 100644 index 0000000..02669bd --- /dev/null +++ b/internal/ownership/components_test.go @@ -0,0 +1,117 @@ +package ownership + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dapi/memory-bank-cli/internal/contracts" +) + +// Producer/consumer integration uses the actual candidate payload, avoiding a +// miniature manifest that cannot expose navigation/selection incompatibilities. +func TestComponentPayloadMatrix(t *testing.T) { + source := os.Getenv("MEMORY_BANK_COMPONENT_SOURCE") + if source == "" { + t.Skip("set MEMORY_BANK_COMPONENT_SOURCE to the template checkout") + } + for _, preset := range []string{"core", "docs", "full", "legacy"} { + t.Run(preset, func(t *testing.T) { + root := t.TempDir() + o := Options{RepoRoot: root, SourceRoot: source, TemplateVersion: "candidate", SourceRef: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Preset: preset, verifySource: func(string, string) error { return nil }} + r, err := Init(o) + if err != nil || !r.Applied || r.ConflictCount != 0 { + t.Fatalf("init: %+v %v", r, err) + } + lock, _, err := ReadLock(root) + if err != nil { + t.Fatal(err) + } + if lock.SchemaVersion != 2 || lock.Installation.Preset != preset { + t.Fatal(lock) + } + before, err := os.ReadFile(filepath.Join(root, LockFileName)) + if err != nil { + t.Fatal(err) + } + o.Preset = "" + r, err = Update(o) + if err != nil || r.Applied || r.ConflictCount != 0 { + t.Fatalf("repeat: %+v %v", r, err) + } + after, _ := os.ReadFile(filepath.Join(root, LockFileName)) + if string(before) != string(after) { + t.Fatal("no-op changed lock") + } + handled, findings, nav, err := ValidateComponents(root, "") + if !handled || err != nil || len(findings) != 0 || nav.ExitCode != 0 { + t.Fatalf("audit: %v %+v %+v %v", handled, findings, nav.Errors, err) + } + if preset == "docs" { + o.Preset = "full" + r, err = Update(o) + if err != nil || !r.Applied { + t.Fatalf("upgrade: %+v %v", r, err) + } + if _, err = os.Stat(filepath.Join(root, contracts.RegistryPath)); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestComponentDocumentsLifecycle(t *testing.T) { + source := os.Getenv("MEMORY_BANK_COMPONENT_SOURCE") + if source == "" { + t.Skip("set MEMORY_BANK_COMPONENT_SOURCE") + } + root := t.TempDir() + o := Options{RepoRoot: root, SourceRoot: source, TemplateVersion: "candidate", SourceRef: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Preset: "legacy", verifySource: func(string, string) error { return nil }} + if r, err := Init(o); err != nil || !r.Applied { + t.Fatalf("init: %+v %v", r, err) + } + p := "memory-bank/features/FT-123/brief.md" + d := DocumentOptions{RepoRoot: root, Operation: "create", Type: "feature", Path: p} + if r, err := DocumentOperation(d); err != nil || !r.Applied { + t.Fatalf("base create: %+v %v", r, err) + } + b, _ := os.ReadFile(filepath.Join(root, p)) + parsed, err := contracts.ParseDocument(p, b) + if err != nil || parsed.Has("document_id") || parsed.Has("flow_contract") { + t.Fatalf("implicit adoption: %+v %v", parsed, err) + } + b = []byte(strings.Replace(string(b), "status: draft\n", "status: draft\ndelivery_status: planned\n", 1)) + if err = os.WriteFile(filepath.Join(root, p), b, 0644); err != nil { + t.Fatal(err) + } + d.Operation = "adopt" + d.LegacyFlow = true + if r, err := DocumentOperation(d); err != nil || !r.Applied { + t.Fatalf("adopt: %+v %v", r, err) + } + if r, err := DocumentOperation(d); err != nil || r.Applied { + t.Fatalf("repeat adopt: %+v %v", r, err) + } + b, _ = os.ReadFile(filepath.Join(root, p)) + parsed, _ = contracts.ParseDocument(p, b) + move := DocumentOptions{RepoRoot: root, Operation: "move", Path: p, To: "memory-bank/features/FT-123/proposal.md", ID: parsed.String("document_id")} + if r, err := DocumentOperation(move); err != nil || !r.Applied { + t.Fatalf("move: %+v %v", r, err) + } + moved, _ := os.ReadFile(filepath.Join(root, move.To)) + if string(moved) != string(b) { + t.Fatal("move rewrote document") + } + if r, err := DocumentOperation(move); err != nil || r.Applied { + t.Fatalf("repeat move: %+v %v", r, err) + } + move.ID = "doc-" + strings.Repeat("0", 64) + if _, err := DocumentOperation(move); err == nil { + t.Fatal("wrong retry identity accepted") + } + if handled, findings, nav, err := ValidateComponents(root, ""); !handled || err != nil || len(findings) > 0 || nav.ExitCode != 0 { + t.Fatalf("audit: %v %+v %+v %v", handled, findings, nav.Errors, err) + } +} diff --git a/internal/ownership/documents.go b/internal/ownership/documents.go new file mode 100644 index 0000000..13e4919 --- /dev/null +++ b/internal/ownership/documents.go @@ -0,0 +1,395 @@ +package ownership + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/dapi/memory-bank-cli/internal/contracts" +) + +type DocumentOptions struct { + RepoRoot string + Operation string + Type string + Path string + To string + ID string + Contract string + LegacyFlow bool + Evidence []string + DryRun bool + BeforeMutation func(Decision) error +} + +func DocumentOperation(o DocumentOptions) (Report, error) { + if !componentHost() { + return Report{}, errors.New("component mutations require Linux or macOS") + } + if !contracts.Contains([]string{"create", "adopt", "transition", "move"}, o.Operation) { + return Report{}, errors.New("unsupported document operation") + } + if !contracts.DocumentPath(o.Path) { + return Report{}, errors.New("document path must be project Markdown under memory-bank") + } + if o.Operation == "move" && (!contracts.DocumentPath(o.To) || o.ID == "") { + return Report{}, errors.New("move requires --id and a project Markdown --to path") + } + if o.Operation != "move" && (o.To != "" || o.ID != "") { + return Report{}, errors.New("--to/--id are only supported by move") + } + if o.Operation == "move" && (o.Contract != "" || o.Type != "" || o.LegacyFlow || len(o.Evidence) > 0) { + return Report{}, errors.New("move only accepts identity and paths") + } + if o.LegacyFlow && o.Contract != "" { + return Report{}, errors.New("--legacy-flow and --contract are mutually exclusive") + } + repo, err := pinRepoRoot(o.RepoRoot) + if err != nil { + return Report{}, err + } + if err = checkComponentRecovery(repo, !o.DryRun); err != nil { + return Report{}, err + } + lock, exists, _, err := readLockSnapshot(repo) + if err != nil { + return Report{}, err + } + if !exists || lock.SchemaVersion != 2 || lock.Installation == nil || !lock.Installation.Has("documents") { + return Report{}, errors.New("document operations require a schema-2 Documents installation") + } + extra := []string{o.Path} + if o.To != "" { + extra = append(extra, o.To) + } + tree, err := readComponentTree(repo, lock, extra) + if err != nil { + return Report{}, err + } + state, err := decodeComponentState(tree.files, lock, "AGENTS.md", true) + if err != nil { + return Report{}, err + } + if len(state.findings) > 0 { + return Report{}, fmt.Errorf("installed document gates fail: %v", state.findings) + } + for name, f := range lock.Files { + if f.Ownership == Managed && tree.observed[name].Mode != f.PayloadMode { + return Report{}, fmt.Errorf("managed mode drift: %s", name) + } + } + for _, p := range extra { + if f, ok := lock.Files[p]; ok && (f.Ownership == Managed || f.Ownership == Generated) { + return Report{}, fmt.Errorf("managed/generated document mutation forbidden: %s", p) + } + } + p := componentPlan{tree: tree, next: lock, manifest: state.manifest, report: Report{FormatVersion: 1, DryRun: o.DryRun, Decisions: []Decision{}}} + installation := *lock.Installation + p.next.Installation = &installation + p.next.Files = map[string]File{} + for name, f := range lock.Files { + p.next.Files[name] = f + } + binding := contracts.Binding{} + for _, b := range state.bindings { + if b.Identity.Path == o.Path { + binding = b + } + } + typ := o.Type + if typ == "" { + if d, ok := state.docs[o.Path]; ok { + typ = d.String("document_type") + } + } + if typ == "" && binding.Identity.ID != "" { + typ = binding.Identity.Type + } + if o.Operation != "move" { + if _, ok := state.catalog.Types[typ]; !ok { + return Report{}, errors.New("known --type or existing document_type is required") + } + } + if o.LegacyFlow { + if installation.LegacySourceRef == "" { + return Report{}, errors.New("installation has no pinned legacy creation contract") + } + o.Contract = state.manifest.LegacySources[installation.LegacySourceRef].Contracts[typ] + } + adopting := o.Contract != "" || o.Operation != "create" + if adopting && !installation.Has("flows") { + return Report{}, errors.New("adoption requires installed Flows") + } + if o.Operation != "move" && adopting { + b, ok := state.catalog.Bundles[o.Contract] + if !ok || b.Type != typ { + return Report{}, errors.New("installed matching --contract is required") + } + } + evidenceSet := map[string]bool{} + for _, ref := range o.Evidence { + evidenceSet[ref] = true + } + evidence := contracts.Keys(evidenceSet) + sort.Strings(evidence) + if !contracts.SortedSet(evidence) { + return Report{}, errors.New("evidence must contain unique nonempty references") + } + for _, ref := range evidence { + if strings.TrimSpace(ref) != ref || strings.ContainsAny(ref, "\r\n\x00") { + return Report{}, errors.New("invalid evidence reference") + } + } + registry := state.registry + // A retry is accepted only with the current complete identity and last move. + if o.Operation == "move" && !tree.observed[o.Path].Exists { + b, ok := state.bindings[o.ID] + if ok && b.Identity.Path == o.To && len(registry.History) > 0 { + last := registry.History[len(registry.History)-1] + if last.Operation == "move" && last.DocumentID == o.ID && last.FromPath == o.Path && last.ToPath == o.To { + return p.report, nil + } + } + return Report{}, errors.New("move source missing or retry identity/history mismatch") + } + raw := tree.files[o.Path] + switch o.Operation { + case "create": + if tree.observed[o.Path].Exists { + return Report{}, errors.New("document already exists") + } + raw = state.catalog.Files[state.catalog.Types[typ].Template] + case "adopt": + if !tree.observed[o.Path].Exists { + return Report{}, errors.New("document missing") + } + if binding.Identity.ID != "" { + if binding.Identity.Type == typ && binding.ContractID == o.Contract { + return p.report, nil + } + return Report{}, errors.New("already adopted; use transition") + } + case "transition": + if binding.Identity.ID == "" { + return Report{}, errors.New("transition requires current adoption") + } + if typ != binding.Identity.Type { + return Report{}, errors.New("transition cannot change document type") + } + if err = state.catalog.ValidateTransition(binding.ContractID, o.Contract, typ, evidence); err != nil { + return Report{}, err + } + if findings := contracts.ValidateBundle(state.docs[o.Path], state.catalog.Bundles[binding.ContractID], binding.Identity, state.docs); len(findings) > 0 { + return Report{}, fmt.Errorf("current contract gates fail: %v", findings) + } + case "move": + if binding.Identity.ID != o.ID { + return Report{}, errors.New("move identity mismatch") + } + if tree.observed[o.To].Exists || o.To == o.Path { + return Report{}, errors.New("move destination exists") + } + if !strings.HasPrefix(o.To, binding.Identity.ContextRoot+"/") { + return Report{}, errors.New("move cannot change adoption context") + } + } + if o.Operation == "move" { + // The original document stays byte-identical. Owned navigation references + // are updated separately; references inside the moved document are audited. + p.add(o.To, raw, UserOwned, "move adopted document") + last := len(p.mutations) - 1 + p.mutations[last].mode = pMode(tree.observed[o.Path].Permissions) + p.mutations = append(p.mutations, mutation{decision: Decision{Path: o.Path, Action: Delete, Ownership: UserOwned, Reason: "move adopted document"}, expectedExists: true, expectedDigest: tree.observed[o.Path].Digest, expectedMode: tree.observed[o.Path].Mode}) + p.report.Decisions = append(p.report.Decisions, p.mutations[len(p.mutations)-1].decision) + for i := range registry.Records { + if registry.Records[i].ID == o.ID { + registry.Records[i].Path = o.To + } + } + for i := range registry.Selectors { + for k := range registry.Selectors[i].Snapshot { + if registry.Selectors[i].Snapshot[k].ID == o.ID && !contracts.Contains(registry.Selectors[i].Exclusions, o.ID) { + registry.Selectors[i].Snapshot[k].Path = o.To + } + } + } + registry.History = append(registry.History, contracts.Event{Operation: "move", DocumentID: o.ID, FromPath: o.Path, ToPath: o.To, FromContract: binding.ContractID, ToContract: binding.ContractID, Evidence: []string{}}) + if err = p.moveNavigation(o.Path, o.To); err != nil { + return Report{}, err + } + delete(p.next.Files, o.Path) + } else { + projection := map[string]string{"document_type": typ} + if adopting { + id := binding.Identity.ID + contextRoot := binding.Identity.ContextRoot + if id == "" { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return Report{}, err + } + id = "doc-" + hex.EncodeToString(b) + contextRoot, err = contracts.ContextRoot(o.Path, typ) + if err != nil { + return Report{}, err + } + } + projection["document_id"] = id + projection["flow_contract"] = o.Contract + rec := contracts.Record{ID: id, Path: o.Path, Type: typ, ContextRoot: contextRoot, ContractID: o.Contract, BundleDigest: state.manifest.Contracts[o.Contract].Digest} + found := false + for i := range registry.Records { + if registry.Records[i].ID == id { + registry.Records[i] = rec + found = true + } + } + if !found { + registry.Records = append(registry.Records, rec) + } + if binding.SelectorID != "" { + for i := range registry.Selectors { + if registry.Selectors[i].ID == binding.SelectorID { + registry.Selectors[i].Exclusions = append(registry.Selectors[i].Exclusions, id) + sort.Strings(registry.Selectors[i].Exclusions) + } + } + } + fromPath := o.Path + if o.Operation == "create" { + fromPath = "" + } + registry.History = append(registry.History, contracts.Event{Operation: o.Operation, DocumentID: id, FromPath: fromPath, ToPath: o.Path, FromContract: binding.ContractID, ToContract: o.Contract, Evidence: evidence}) + } + raw, err = contracts.Project(raw, projection) + if err != nil { + return Report{}, err + } + p.add(o.Path, raw, UserOwned, "write explicit document projection") + if o.Operation == "create" { + if err = p.addNavigation(o.Path); err != nil { + return Report{}, err + } + } + } + if _, tracked := p.next.Files[o.Path]; tracked && o.Operation != "move" { + p.next.Files[o.Path] = File{Ownership: UserOwned} + } + if installation.Has("flows") { + sort.Slice(registry.Records, func(i, j int) bool { return registry.Records[i].ID < registry.Records[j].ID }) + b, e := contracts.RegistryBytes(registry) + if e != nil { + return Report{}, e + } + installation.AdoptionDigest = digest(b) + p.add(contracts.RegistryPath, b, Generated, "record adoption history") + } + nextState, err := decodeComponentState(p.future(), p.next, "AGENTS.md", true) + if err != nil { + return Report{}, err + } + if len(nextState.findings) > 0 { + return Report{}, fmt.Errorf("document gates fail: %v", nextState.findings) + } + nav, err := componentNavigation(p.future(), state.manifest, installation) + if err != nil { + return Report{}, err + } + if nav.ExitCode != 0 { + return Report{}, fmt.Errorf("document navigation invalid: %+v", nav.Errors) + } + p.next.LastUpdate = UpdateRecord{lock.Template.Version, time.Now().UTC()} + b, err := marshalLock(p.next) + if err != nil { + return Report{}, err + } + p.add(LockFileName, b, Generated, "record document transaction") + return applyComponentPlan(Options{RepoRoot: repo.root, DryRun: o.DryRun, BeforeMutation: o.BeforeMutation}, repo, p) +} + +func (p *componentPlan) addNavigation(document string) error { + parent := path.Dir(document) + index := parent + "/README.md" + if document == index { + parent = path.Dir(parent) + index = parent + "/README.md" + } + if parent == "." { + return errors.New("cannot index outside memory-bank") + } + ref, err := relativeDocumentPath(parent, document) + if err != nil { + return err + } + if _, ok := p.tree.files[index]; !ok { + data := []byte("---\nstatus: draft\ndoc_function: index\npurpose: Navigate project documents.\n---\n\n# " + path.Base(parent) + "\n\n- [" + path.Base(document) + "](" + ref + ") — project document.\n") + if err := p.observeNew(index); err != nil { + return err + } + p.add(index, data, UserOwned, "create project document index") + return p.addNavigation(index) + } + f, tracked := p.next.Files[index] + if tracked && (f.Ownership == Managed || f.Ownership == Generated) { + return fmt.Errorf("managed index requires an explicit project override: %s", index) + } + data := append([]byte{}, p.tree.files[index]...) + if len(data) > 0 && data[len(data)-1] != '\n' { + data = append(data, '\n') + } + data = append(data, []byte("\n- ["+path.Base(document)+"]("+ref+") — project document.\n")...) + p.add(index, data, UserOwned, "index created document") + if tracked { + p.next.Files[index] = File{Ownership: UserOwned} + } + return nil +} +func (p *componentPlan) observeNew(name string) error { + // readComponentTree already scanned memory-bank; a missing key is an observed + // absence only after a no-follow destination check using the pinned caller. + if _, ok := p.tree.observed[name]; !ok { + p.tree.observed[name] = observation{} + } + return nil +} +func (p *componentPlan) moveNavigation(from, to string) error { + for _, name := range contracts.Keys(p.tree.files) { + if !strings.HasSuffix(name, ".md") || name == from { + continue + } + if !strings.HasPrefix(name, "memory-bank/") { + continue + } + oldRef, err := relativeDocumentPath(path.Dir(name), from) + if err != nil { + return err + } + newRef, err := relativeDocumentPath(path.Dir(name), to) + if err != nil { + return err + } + before := string(p.tree.files[name]) + after := strings.ReplaceAll(before, "]("+oldRef+")", "]("+newRef+")") + if after == before { + continue + } + if f, ok := p.next.Files[name]; ok && (f.Ownership == Managed || f.Ownership == Generated) { + return fmt.Errorf("managed reference blocks move: %s", name) + } + p.add(name, []byte(after), UserOwned, "update project navigation after move") + } + return nil +} + +func pMode(s string) os.FileMode { var m os.FileMode; fmt.Sscanf(s, "%o", &m); return m } +func relativeDocumentPath(from, to string) (string, error) { + p, e := filepath.Rel(filepath.FromSlash(from), filepath.FromSlash(to)) + return filepath.ToSlash(p), e +} diff --git a/internal/ownership/lock.go b/internal/ownership/lock.go index 9108ed2..244d134 100644 --- a/internal/ownership/lock.go +++ b/internal/ownership/lock.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/dapi/memory-bank-cli/internal/contracts" "io" "os" "path" @@ -52,9 +53,19 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) { } // Schema 0 was the unversioned prototype. Its fields have v1 semantics and // are rewritten as v1 after the next successful update. - if lock.SchemaVersion != 0 && lock.SchemaVersion != CurrentSchemaVersion { + if lock.SchemaVersion != 0 && lock.SchemaVersion != CurrentSchemaVersion && lock.SchemaVersion != 2 { return Lock{}, false, "", fmt.Errorf("unsupported memory-bank lock schema %d (supported: %d)", lock.SchemaVersion, CurrentSchemaVersion) } + if lock.SchemaVersion == 2 { + if err := contracts.Decode(data, &lock); err != nil { + return Lock{}, false, "", err + } + if lock.Installation == nil { + return Lock{}, false, "", errors.New("schema-2 installation missing") + } + } else if lock.Installation != nil { + return Lock{}, false, "", errors.New("legacy lock contains component installation") + } if lock.Files == nil { lock.Files = make(map[string]File) } @@ -65,7 +76,7 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) { return Lock{}, false, "", fmt.Errorf("invalid last update in %s", LockFileName) } for filePath, file := range lock.Files { - if filePath == LockFileName || path.IsAbs(filePath) || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || strings.HasPrefix(filePath, "../") || filePath == "." || isGitMetadataPath(filePath) { + if filePath == contracts.RegistryPath || filePath == LockFileName || path.IsAbs(filePath) || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || strings.HasPrefix(filePath, "../") || filePath == "." || isGitMetadataPath(filePath) { return Lock{}, false, "", fmt.Errorf("invalid path %q in %s", filePath, LockFileName) } switch file.Ownership { @@ -73,14 +84,14 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) { if !digestPattern.MatchString(file.BaseDigest) || !digestPattern.MatchString(file.PayloadDigest) { return Lock{}, false, "", fmt.Errorf("invalid digest contract for %s", filePath) } - if lock.SchemaVersion == CurrentSchemaVersion && (!modePattern.MatchString(file.BaseMode) || !modePattern.MatchString(file.PayloadMode)) { + if lock.SchemaVersion >= CurrentSchemaVersion && (!modePattern.MatchString(file.BaseMode) || !modePattern.MatchString(file.PayloadMode)) { return Lock{}, false, "", fmt.Errorf("invalid mode contract for %s", filePath) } case Adapted: if !digestPattern.MatchString(file.BaseDigest) { return Lock{}, false, "", fmt.Errorf("invalid base digest for %s", filePath) } - if lock.SchemaVersion == CurrentSchemaVersion && !modePattern.MatchString(file.BaseMode) { + if lock.SchemaVersion >= CurrentSchemaVersion && !modePattern.MatchString(file.BaseMode) { return Lock{}, false, "", fmt.Errorf("invalid base mode for %s", filePath) } case UserOwned: diff --git a/internal/ownership/source_format.go b/internal/ownership/source_format.go index df0d77f..29c85b2 100644 --- a/internal/ownership/source_format.go +++ b/internal/ownership/source_format.go @@ -36,13 +36,26 @@ func verifySourceFormat(root, ref, payloadRoot string) error { if err != nil { return fmt.Errorf("inspect component marker: %w", err) } - if entry != "" { - return errors.New("unsupported component source: this bridge supports only legacy/v1; upgrade the CLI before installing components") - } data, exists, err := readSourceDeclaration(root, ref) if err != nil { return err } + if entry != "" { + if !componentHost() { + return errors.New("component sources require Linux or macOS") + } + if !exists { + return errors.New("unsupported component source: declaration missing") + } + d, e := decodeSourceDeclaration(data) + if e != nil { + return e + } + if d.SchemaVersion != 1 || d.PayloadFormat != "components/v1" || len(d.Capabilities) != 2 || d.Capabilities[0] != "adoption/v1" || d.Capabilities[1] != "components/v1" { + return errors.New("unsupported component source: invalid declaration") + } + return nil + } if !exists { for _, allowed := range SupportedLegacySourceRefs() { if strings.EqualFold(ref, allowed) { diff --git a/internal/ownership/types.go b/internal/ownership/types.go index 4d7f571..531253d 100644 --- a/internal/ownership/types.go +++ b/internal/ownership/types.go @@ -1,7 +1,10 @@ // Package ownership implements the versioned Memory Bank ownership and update contract. package ownership -import "time" +import ( + "github.com/dapi/memory-bank-cli/internal/contracts" + "time" +) const ( LockFileName = "memory-bank/.lock" @@ -38,10 +41,11 @@ type File struct { } type Lock struct { - SchemaVersion int `json:"schema_version"` - Template Template `json:"template"` - LastUpdate UpdateRecord `json:"last_update"` - Files map[string]File `json:"files"` + Installation *contracts.Installation `json:"installation,omitempty"` + SchemaVersion int `json:"schema_version"` + Template Template `json:"template"` + LastUpdate UpdateRecord `json:"last_update"` + Files map[string]File `json:"files"` } type Action string @@ -119,11 +123,18 @@ type AdaptedResolution struct { } type Options struct { - RepoRoot string - SourceRoot string - TemplateVersion string - SourceRef string - DryRun bool + Preset string + Adapters []string + MigrateComponents bool + MigrationPlanDigest string + MigrationResolution []byte + componentTransaction bool + componentObservations map[string]observation + RepoRoot string + SourceRoot string + TemplateVersion string + SourceRef string + DryRun bool // UserOwnedResolutions maps user-owned managed-file collisions to their // explicit resolution: false keeps local content, true replaces it with the // incoming source payload. diff --git a/internal/ownership/update.go b/internal/ownership/update.go index 69cd017..92802cb 100644 --- a/internal/ownership/update.go +++ b/internal/ownership/update.go @@ -26,16 +26,17 @@ type payload struct { } type mutation struct { - decision Decision - data []byte - mode fs.FileMode - modeSet bool - expectedExists bool - expectedDigest string - expectedMode string - preconditions []destinationPrecondition - topology *topologySnapshot - topologyReplaced bool + decision Decision + data []byte + mode fs.FileMode + modeSet bool + expectedExists bool + expectedDigest string + expectedMode string + expectedPermissions string + preconditions []destinationPrecondition + topology *topologySnapshot + topologyReplaced bool } type destinationPrecondition struct { @@ -44,6 +45,7 @@ type destinationPrecondition struct { exists bool digest string mode string + permissions string } var immutableRefPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}([0-9a-fA-F]{24})?$`) @@ -117,6 +119,12 @@ func run(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest st if err := verifySource(pinnedSource.root, options.SourceRef); err != nil { return Report{}, fmt.Errorf("source checkout changed while reading template: %w", err) } + if hasComponentSource(source) { + return runComponents(options, old, hasLock, repo, lockDigest, source) + } + if old.SchemaVersion == 2 || componentFlags(options) { + return Report{}, errors.New("component selection/state requires a component source") + } // An ordinary pull resolves only mechanically provable adapted-file merges: // a locked historical base is available and the two line edits do not // overlap. Everything else remains a conflict for --plan/--apply-plan (or @@ -949,6 +957,14 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi if !digestExists || (item.expectedDigest != "" && currentDigest != item.expectedDigest) { return fmt.Errorf("prepare %s: destination content changed while update was being planned", item.decision.Path) } + if item.expectedPermissions != "" && item.expectedPermissions != fmt.Sprintf("%04o", info.Mode().Perm()) { + return fmt.Errorf("prepare %s: permissions changed", item.decision.Path) + } + if options.componentTransaction { + if err := checkOriginalComponentFile(info); err != nil { + return err + } + } if item.expectedMode != "" && !modeMatches(observedMode(info.Mode().Perm()), item.expectedMode) { return fmt.Errorf("prepare %s: destination mode changed while update was being planned", item.decision.Path) } @@ -986,6 +1002,13 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi staged[index].replacementDigest = replacementDigest } + var journal *componentJournal + if options.componentTransaction { + journal, err = prepareComponentJournal(repo, options, mutations, staged, staging) + if err != nil { + return err + } + } createdDirectories := make([]string, 0) removedDirectories := make([]removedDirectory, 0) rollback := func() error { @@ -1050,6 +1073,11 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) } if item.decision.Path == LockFileName { + if journal != nil { + if err := verifyComponentInventory(repo, journal.After); err != nil { + return fail(err) + } + } for priorIndex := 0; priorIndex < index; priorIndex++ { prior := &staged[priorIndex] if !prior.originalMoved && !prior.replacementInstalled { @@ -1078,6 +1106,18 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi } } if item.expectedExists { + if options.componentTransaction { + if err := checkOriginalComponentFile(item.originalInfo); err != nil { + return fail(err) + } + info, _, e := secureReadDestination(repo, item.decision.Path) + if e != nil { + return fail(e) + } + if e = checkOriginalComponentFile(info); e != nil { + return fail(e) + } + } if ops.renameFromDestination != nil && sameOperation(ops.rename, os.Rename) { if err := ops.renameFromDestination(repo, item.decision.Path, item.backup); err != nil { return fail(fmt.Errorf("apply %s: move original to staging: %w", item.decision.Path, err)) @@ -1086,6 +1126,11 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi return fail(fmt.Errorf("apply %s: move original to staging: %w", item.decision.Path, err)) } item.originalMoved = true + if journal != nil { + if err := syncComponentParents(repo, item.decision.Path, filepath.Dir(item.backup)); err != nil { + return fail(err) + } + } backupInfo, backupDigest, err := inspectRegularFile(item.backup) if err != nil { return fail(fmt.Errorf("apply %s: inspect staged original: %w", item.decision.Path, err)) @@ -1106,9 +1151,17 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) } } + createdBefore := len(createdDirectories) if err := ensureDestinationParents(repo, item.decision.Path, &createdDirectories); err != nil { return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) } + if journal != nil { + for _, directory := range createdDirectories[createdBefore:] { + if err := chmodComponentDirectory(repo, directory, 0755); err != nil { + return fail(err) + } + } + } if _, _, nowExists, err := inspectDestination(repo, item.decision.Path); err != nil { return fail(fmt.Errorf("apply %s: %w", item.decision.Path, err)) } else if nowExists { @@ -1129,6 +1182,11 @@ func applyAtomicallyPinnedWithOps(options Options, mutations []mutation, repo pi return fail(fmt.Errorf("apply %s: detach installed payload from staging: %w", item.decision.Path, err)) } } + if journal != nil { + if err := commitComponentJournal(repo, journal, staging); err != nil { + return fail(err) + } + } commitComplete = true cleanupStaging = true return nil @@ -1173,6 +1231,10 @@ func verifyOriginalTarget(repo pinnedRepo, item *stagedMutation) error { if !os.SameFile(item.originalInfo, currentInfo) { return errors.New("destination identity changed after staging") } + if item.expectedPermissions != "" && item.expectedPermissions != fmt.Sprintf("%04o", currentInfo.Mode().Perm()) { + return fmt.Errorf("prepare %s: permissions changed", item.decision.Path) + } + if item.expectedMode != "" && !modeMatches(observedMode(currentInfo.Mode().Perm()), item.expectedMode) { return errors.New("destination mode changed after staging") } @@ -1207,6 +1269,14 @@ func verifyDestinationPrecondition(repo pinnedRepo, precondition destinationPrec if !os.SameFile(info, readInfo) { return errors.New("managed payload identity changed before lock commit") } + if precondition.permissions != "" { + if fmt.Sprintf("%04o", readInfo.Mode().Perm()) != precondition.permissions { + return errors.New("component input permissions changed before lock commit") + } + if err := checkOriginalComponentFile(readInfo); err != nil { + return err + } + } currentDigest := digest(data) if currentDigest != precondition.digest { return errors.New("managed payload content changed before lock commit") From 6347e09e7a4bfae8671c4ea6a6c316e2f4f17dfa Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Mon, 7 Sep 2026 06:38:14 +0300 Subject: [PATCH 08/13] feat: complete component migration and explicit adoption runtime --- .github/workflows/local-e2e.yml | 21 + CHANGELOG.md | 10 + internal/cli/capabilities_test.go | 9 +- internal/cli/documents.go | 1 + internal/contracts/draft.go | 30 ++ internal/contracts/draft_test.go | 51 ++ internal/contracts/manifest.go | 21 +- internal/contracts/manifest_test.go | 19 + internal/contracts/priming.go | 136 +++++ internal/contracts/priming_test.go | 21 + internal/contracts/relocate.go | 319 ++++++++++++ internal/contracts/relocate_test.go | 34 ++ internal/contracts/rules.go | 18 + internal/ownership/component_file_unix.go | 7 + internal/ownership/component_file_windows.go | 2 + .../ownership/component_integrity_test.go | 165 ++++++ internal/ownership/component_migration.go | 470 ++++++++++++++++++ .../ownership/component_migration_test.go | 198 ++++++++ internal/ownership/component_recovery.go | 74 ++- internal/ownership/component_resolution.go | 150 ++++++ internal/ownership/component_state.go | 8 +- internal/ownership/components.go | 139 +++++- internal/ownership/components_test.go | 181 +++++++ internal/ownership/documents.go | 60 ++- internal/ownership/resolution_plan.go | 9 + internal/ownership/source_format.go | 8 +- internal/ownership/types.go | 54 +- internal/ownership/update.go | 3 + scripts/e2e-components.py | 77 +++ scripts/e2e-source-format.sh | 4 +- 30 files changed, 2241 insertions(+), 58 deletions(-) create mode 100644 internal/contracts/draft.go create mode 100644 internal/contracts/draft_test.go create mode 100644 internal/contracts/priming.go create mode 100644 internal/contracts/priming_test.go create mode 100644 internal/contracts/relocate.go create mode 100644 internal/contracts/relocate_test.go create mode 100644 internal/ownership/component_integrity_test.go create mode 100644 internal/ownership/component_migration.go create mode 100644 internal/ownership/component_migration_test.go create mode 100644 internal/ownership/component_resolution.go create mode 100644 scripts/e2e-components.py diff --git a/.github/workflows/local-e2e.yml b/.github/workflows/local-e2e.yml index ebbb682..70ef53c 100644 --- a/.github/workflows/local-e2e.yml +++ b/.github/workflows/local-e2e.yml @@ -37,3 +37,24 @@ jobs: E2E_BINARY: ${{ runner.temp }}/memory-bank-cli LEGACY_SOURCE: ${{ github.workspace }}/.fixtures/legacy-source run: bash scripts/e2e-source-format.sh + + - name: Check out reviewed component producer + uses: actions/checkout@v7 + with: + repository: dapi/memory-bank + ref: f695db6a703e5409f10c9988e6460b41068fe30c + path: .fixtures/component-source + persist-credentials: false + - name: Verify component contracts and transaction fixtures + env: + MEMORY_BANK_COMPONENT_SOURCE: ${{ github.workspace }}/.fixtures/component-source + MEMORY_BANK_LEGACY_SOURCE: ${{ github.workspace }}/.fixtures/legacy-source + run: | + go test ./... + go vet ./... + - name: Verify actual-binary component acceptance + env: + E2E_BINARY: ${{ runner.temp }}/memory-bank-cli + MEMORY_BANK_COMPONENT_SOURCE: ${{ github.workspace }}/.fixtures/component-source + MEMORY_BANK_LEGACY_SOURCE: ${{ github.workspace }}/.fixtures/legacy-source + run: python3 scripts/e2e-components.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50bae56..ae659a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +- Install schema-2 component presets (`core`, `docs`, `full`, `legacy`) and additive + adapters on Linux/macOS, with composition-aware README and AGENTS blocks. +- Create base documents independently from explicit flow adoption; bind adopted + documents to immutable contract bundles and support evidence-backed transitions + and identity-preserving moves. +- Migrate the pinned legacy source through deterministic previews, explicit + classification/ownership resolutions and exact plan-digest consent. +- Validate component state before mutations and retain durable recovery journals, + including prepared-draft snapshots, when rollback or cleanup cannot finish. + - Gate installation sources by a pinned legacy compatibility list or strict source-format declaration; reject unknown formats before planning or changing downstream files. - Add the JSON `capabilities --require` handshake for installation entrypoints. diff --git a/internal/cli/capabilities_test.go b/internal/cli/capabilities_test.go index 1a1e762..90f3fe6 100644 --- a/internal/cli/capabilities_test.go +++ b/internal/cli/capabilities_test.go @@ -4,11 +4,16 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "testing" ) func TestCapabilitiesWireContract(t *testing.T) { + componentCode, componentUnsupported, caps := 1, `["components/v1","adoption/v1"]`, `["source-format/v1","legacy/v1"]` + if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { + componentCode, componentUnsupported, caps = 0, `[]`, `["source-format/v1","legacy/v1","components/v1","adoption/v1"]` + } for _, tc := range []struct { args []string code int @@ -16,11 +21,11 @@ func TestCapabilitiesWireContract(t *testing.T) { }{ {nil, 0, `[]`}, {[]string{"--require", "legacy/v1", "--require", "source-format/v1"}, 0, `[]`}, - {[]string{"--require", "components/v1", "--require", "adoption/v1"}, 1, `["components/v1","adoption/v1"]`}, + {[]string{"--require", "components/v1", "--require", "adoption/v1"}, componentCode, componentUnsupported}, } { var out, err bytes.Buffer code := Run(append([]string{"capabilities"}, tc.args...), "test-version", &out, &err) - want := `{"schema_version":1,"cli_version":"test-version","capabilities":["source-format/v1","legacy/v1"],"unsupported":` + tc.unsupported + "}\n" + want := `{"schema_version":1,"cli_version":"test-version","capabilities":` + caps + `,"unsupported":` + tc.unsupported + "}\n" if code != tc.code || out.String() != want || err.Len() != 0 { t.Fatalf("code=%d stdout=%s stderr=%s", code, out.String(), err.String()) } diff --git a/internal/cli/documents.go b/internal/cli/documents.go index aae943d..6ce2c65 100644 --- a/internal/cli/documents.go +++ b/internal/cli/documents.go @@ -26,6 +26,7 @@ func runDocument(arguments []string, stdout, stderr io.Writer) int { flags.SetOutput(stderr) root := addRepoRootFlag(flags) o := ownership.DocumentOptions{Operation: op} + flags.StringVar(&o.From, "from", "", "prepared local draft for atomic creation (repository-relative)") flags.StringVar(&o.Type, "type", "", "installed document type") flags.StringVar(&o.Path, "path", "", "project Markdown path under memory-bank") flags.StringVar(&o.To, "to", "", "move destination within the original context") diff --git a/internal/contracts/draft.go b/internal/contracts/draft.go new file mode 100644 index 0000000..70a7613 --- /dev/null +++ b/internal/contracts/draft.go @@ -0,0 +1,30 @@ +package contracts + +import ( + "bytes" + "errors" + "regexp" +) + +// This deliberately narrow grammar is shared by draft validation and base relocation. +var referenceDestination = `(?:<([^<>\\\r\n]+)>|([^\s()<>\\]+))` +var referenceTitle = `(?:[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^()\r\n]*\)))?` +var draftInlineReference = regexp.MustCompile(`\][ \t]*\([ \t]*` + referenceDestination + referenceTitle + `[ \t]*\)`) +var draftReferenceDefinition = regexp.MustCompile(`(?m)^[ \t]{0,3}\[[^\]\r\n]+\]:[ \t]*` + referenceDestination + referenceTitle + `[ \t]*\r?$`) +var draftReferenceSyntax = regexp.MustCompile(`\]\s*[(:]`) +var draftAutolink = regexp.MustCompile(`<(?:(?:https?://)|mailto:)[^<>\\\s]+>`) +var draftHTML = regexp.MustCompile(`<[!/A-Za-z]`) +var referenceEntity = regexp.MustCompile(`&(?:#[0-9]+|#x[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]+);`) + +// Drafts are copied verbatim: any reference that relocation would change is +// rejected. This also validates the exact YAML and Markdown syntax once. +func ValidateDraftCopy(d Document, target string) error { + relocated, err := RelocateBaseDocument(d.Raw, d.Path, target) + if err != nil { + return err + } + if !bytes.Equal(relocated, d.Raw) { + return errors.New("cross-directory draft has relative references; use repository-absolute references") + } + return nil +} diff --git a/internal/contracts/draft_test.go b/internal/contracts/draft_test.go new file mode 100644 index 0000000..1a490b1 --- /dev/null +++ b/internal/contracts/draft_test.go @@ -0,0 +1,51 @@ +package contracts + +import "testing" + +func TestDraftCopyRejectsRelativeReferences(t *testing.T) { + for _, body := range []string{"[source](../source.md)", "[source]: ../source.md", "![image](image.png)", "link", "[link]:\n relative.md"} { + d, e := ParseDocument("drafts/input.md", []byte("---\nstatus: draft\n---\n"+body)) + if e != nil { + t.Fatal(e) + } + if e = ValidateDraftCopy(d, "memory-bank/features/FT-1/brief.md"); e == nil { + t.Fatalf("relative reference accepted: %s", body) + } + } + for _, body := range []string{"[source](/memory-bank/README.md)", "[site](https://example.org)", "[self](#heading)", "plain draft", "```\n[example](relative.md)\n```"} { + d, e := ParseDocument("drafts/input.md", []byte("---\nstatus: draft\n---\n"+body)) + if e != nil { + t.Fatal(e) + } + if e = ValidateDraftCopy(d, "memory-bank/features/FT-1/brief.md"); e != nil { + t.Fatalf("independent draft rejected: %s: %v", body, e) + } + } +} + +func TestDraftGrammarVectors(t *testing.T) { + for _, tc := range []struct { + text string + valid bool + }{ + {`[x](/memory-bank/README.md "Index")`, true}, + {`![x](https://example.org/x.png)`, true}, + {`[x]: `, true}, + {`[x](https://example.org/a(b))`, false}, + {`[x](https://example.org/a&b)`, false}, + {"`[example](relative.md)`", true}, + {"", true}, + {"---\nstatus: draft\nderived_from: [{path: /memory-bank/README.md, fit: exact}]\n---\n", true}, + {"---\nstatus: draft\nderived_from: &dep [/memory-bank/README.md]\n---\n", false}, + {"---\nstatus: draft\nderived_from: {path: [relative.md]}\n---\n", false}, + } { + d, err := ParseDocument("drafts/input.md", []byte(tc.text)) + if err != nil { + t.Fatal(err) + } + err = ValidateDraftCopy(d, "memory-bank/features/FT-1/brief.md") + if (err == nil) != tc.valid { + t.Fatalf("%q valid=%v: %v", tc.text, tc.valid, err) + } + } +} diff --git a/internal/contracts/manifest.go b/internal/contracts/manifest.go index f0195b0..290ac1d 100644 --- a/internal/contracts/manifest.go +++ b/internal/contracts/manifest.go @@ -142,7 +142,7 @@ func ReadManifest(data []byte, inventory map[string][]byte) (Manifest, error) { return m, err } for p, f := range m.Files { - if !ValidPath(p) || p == "AGENTS.md" || p == RegistryPath || p == "memory-bank/.lock" { + if !ValidPath(p) || p == "AGENTS.md" || p == RegistryPath || p == "memory-bank/.lock" || strings.HasPrefix(p, ".memory-bank-update-") || strings.HasPrefix(p, "memory-bank/.repo/") { return m, fmt.Errorf("reserved or unsafe payload path %s", p) } if _, ok := inventory[p]; inventory != nil && !ok { @@ -345,9 +345,22 @@ func (m Manifest) ValidateInstallation(s Installation) error { return errors.New("invalid installed adapter") } } - wanted, err := m.Select(s.Preset, s.Adapters, nil) - if err != nil || !reflect.DeepEqual(wanted.Components, s.Components) || !reflect.DeepEqual(wanted.Adapters, s.Adapters) { - return errors.New("installed closure does not match preset and adapters") + if s.Preset == "legacy" { + // The legacy preset is an installation default, not permission to adopt + // adapters newly marked legacy in a subsequent source. + if !reflect.DeepEqual(s.Components, []string{"dna", "documents", "flows"}) { + return errors.New("invalid legacy component closure") + } + ids := append(append([]string{}, s.Components...), s.Adapters...) + closure, e := m.closure(ids) + if e != nil || !reflect.DeepEqual(Keys(closure), sorted(ids)) { + return errors.New("invalid locked legacy adapter closure") + } + } else { + wanted, err := m.Select(s.Preset, s.Adapters, nil) + if err != nil || !reflect.DeepEqual(wanted.Components, s.Components) || !reflect.DeepEqual(wanted.Adapters, s.Adapters) { + return errors.New("installed closure does not match preset and adapters") + } } if s.LegacySourceRef != "" { if _, ok := m.LegacySources[s.LegacySourceRef]; !ok || !s.Has("flows") { diff --git a/internal/contracts/manifest_test.go b/internal/contracts/manifest_test.go index ac3dc0c..62a521b 100644 --- a/internal/contracts/manifest_test.go +++ b/internal/contracts/manifest_test.go @@ -126,3 +126,22 @@ func TestMissingRequiredField(t *testing.T) { t.Fatal("missing legacy field was accepted") } } + +func TestLockedLegacyDoesNotAdoptNewDefaultAdapters(t *testing.T) { + m, _ := fixture() + s, err := m.Select("legacy", nil, nil) + if err != nil { + t.Fatal(err) + } + s.ManifestDigest = Digest([]byte("manifest")) + s.AdoptionDigest = Digest([]byte("registry")) + m.Components["newadapter"] = Component{Dependencies: []string{"flows"}, Adapter: true, Legacy: true} + m.Presets["legacy"] = []string{"codex", "dna", "documents", "flows", "newadapter"} + kept, err := m.Select("", nil, &s) + if err != nil || kept.Has("newadapter") { + t.Fatalf("implicit adapter: %+v %v", kept, err) + } + if err = m.ValidateInstallation(kept); err != nil { + t.Fatalf("old closure rejected: %v", err) + } +} diff --git a/internal/contracts/priming.go b/internal/contracts/priming.go new file mode 100644 index 0000000..51fd7f9 --- /dev/null +++ b/internal/contracts/priming.go @@ -0,0 +1,136 @@ +package contracts + +import ( + "bytes" + "errors" + "fmt" + "io" + "path" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +var primingIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) +var primingPlaceholder = regexp.MustCompile(`<[A-Z][A-Z0-9-]*>`) +var primingExternal = regexp.MustCompile(`(?i)^[a-z][a-z0-9+.-]*://`) + +// ValidatePriming mirrors the payload's version-1 priming schema without +// executing the manifests or consulting the human prompt catalog as workflow. +func ValidatePriming(files map[string][]byte) error { + processes := map[string]bool{} + for _, p := range Keys(files) { + template := p == "memory-bank/flows/templates/process/priming.yaml" + if !template && !(path.Dir(p) == "memory-bank/flows/priming" && strings.HasSuffix(p, ".yaml")) { + continue + } + var tree yaml.Node + if err := yaml.Unmarshal(files[p], &tree); err != nil { + return err + } + var ordinary func(*yaml.Node) bool + ordinary = func(n *yaml.Node) bool { + if n.Kind == yaml.AliasNode || n.Anchor != "" || n.Style&yaml.TaggedStyle != 0 { + return false + } + for _, child := range n.Content { + if !ordinary(child) { + return false + } + } + return true + } + if !ordinary(&tree) { + return errors.New("priming aliases, anchors and tags are unsupported") + } + var raw map[string]any + decoder := yaml.NewDecoder(bytes.NewReader(files[p])) + decoder.KnownFields(true) + if err := decoder.Decode(&raw); err != nil { + return fmt.Errorf("%s: %w", p, err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("%s: trailing YAML", p) + } + if len(raw) != 3 || raw["version"] != 1 { + return fmt.Errorf("%s: invalid priming schema", p) + } + for _, key := range []string{"version", "process", "stages"} { + if _, ok := raw[key]; !ok { + return fmt.Errorf("%s: priming field missing", p) + } + } + process, ok := raw["process"].(string) + if !ok || (!template && !primingIdentifier.MatchString(process)) { + return errors.New("invalid priming process") + } + if !template { + if processes[process] { + return errors.New("duplicate priming process") + } + processes[process] = true + } + stages, ok := raw["stages"].(map[string]any) + if !ok || len(stages) == 0 { + return errors.New("priming stages must be nonempty mapping") + } + for stage, value := range stages { + if !primingIdentifier.MatchString(stage) { + return errors.New("invalid priming stage") + } + values, ok := value.([]any) + if !ok || len(values) == 0 { + return errors.New("priming inputs must be nonempty list") + } + seen := map[string]bool{} + for _, value := range values { + input, ok := value.(string) + if !ok || input == "" || seen[input] { + return errors.New("invalid or duplicate priming input") + } + seen[input] = true + if primingExternal.MatchString(input) { + continue + } + if !strings.HasPrefix(input, "memory-bank/") || strings.Contains(input, "\\") || strings.Contains(strings.ToUpper(input), "TODO") || strings.Contains(input, "**") || strings.ContainsAny(input, "?[]{}") || path.Clean(input) != input || strings.Contains(input, "/../") { + return fmt.Errorf("unsafe priming input %s", input) + } + if template { + continue + } + remaining := primingPlaceholder.ReplaceAllString(input, "") + if strings.ContainsAny(remaining, "<>") { + return errors.New("invalid priming placeholder") + } + found := false + if strings.Contains(input, "<") { + prefix := strings.SplitN(input, "<", 2)[0] + for candidate := range files { + if strings.HasPrefix(candidate, prefix) { + found = true + break + } + } + } else if strings.Contains(input, "*") { + for candidate := range files { + match, err := path.Match(input, candidate) + if err != nil { + return err + } + if match { + found = true + break + } + } + } else { + _, found = files[input] + } + if !found { + return fmt.Errorf("%s: unresolved priming input %s", p, input) + } + } + } + } + return nil +} diff --git a/internal/contracts/priming_test.go b/internal/contracts/priming_test.go new file mode 100644 index 0000000..638c5da --- /dev/null +++ b/internal/contracts/priming_test.go @@ -0,0 +1,21 @@ +package contracts + +import "testing" + +func TestPrimingPreflightChecksWholeInventory(t *testing.T) { + p := "memory-bank/flows/priming/test.yaml" + good := map[string][]byte{p: []byte("version: 1\nprocess: test\nstages:\n entry:\n - memory-bank/dna/*.md\n"), "memory-bank/dna/README.md": []byte("index")} + if err := ValidatePriming(good); err != nil { + t.Fatal(err) + } + for _, input := range []string{"memory-bank/missing.md", "memory-bank/../outside.md", "memory-bank/dna/**", "memory-bank//x.md"} { + bad := map[string][]byte{} + for k, v := range good { + bad[k] = v + } + bad[p] = []byte("version: 1\nprocess: test\nstages:\n entry:\n - " + input + "\n") + if err := ValidatePriming(bad); err == nil { + t.Fatalf("invalid input accepted: %s", input) + } + } +} diff --git a/internal/contracts/relocate.go b/internal/contracts/relocate.go new file mode 100644 index 0000000..27a5f6d --- /dev/null +++ b/internal/contracts/relocate.go @@ -0,0 +1,319 @@ +package contracts + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/url" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type textReplacement struct { + start, end int + value []byte +} + +// RelocateBaseDocument preserves the resolved target of supported Markdown and +// derived_from references while instantiating a base template at another path. +// It changes only reference tokens, never the source file or unrelated text. +func RelocateBaseDocument(data []byte, from, to string) ([]byte, error) { + if !ValidPath(from) || !ValidPath(to) { + return nil, errors.New("unsafe template relocation path") + } + if path.Dir(from) == path.Dir(to) { + return append([]byte{}, data...), nil + } + d, err := ParseDocument(from, data) + if err != nil { + return nil, err + } + relocate := func(ref string) (string, error) { + if strings.ContainsAny(ref, "\\\r\n") || referenceEntity.MatchString(ref) { + return "", errors.New("escaped references are unsupported") + } + if ref == "" || strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, "#") || strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "mailto:") { + return ref, nil + } + if strings.ContainsAny(ref, "\\\r\n") { + return "", errors.New("unsupported relative reference syntax") + } + bare, suffix := ref, "" + if i := strings.IndexAny(ref, "?#"); i >= 0 { + bare, suffix = ref[:i], ref[i:] + } + decoded, e := url.PathUnescape(bare) + if e != nil || strings.HasPrefix(decoded, "/") || strings.ContainsAny(decoded, "\\\r\n") { + return "", errors.New("unsupported encoded relative destination") + } + target := path.Clean(path.Join(path.Dir(from), decoded)) + if !ValidPath(target) { + return "", fmt.Errorf("template reference escapes repository: %s", ref) + } + rel, e := filepath.Rel(filepath.FromSlash(path.Dir(to)), filepath.FromSlash(target)) + if e != nil { + return "", e + } + rel = filepath.ToSlash(rel) + if strings.HasSuffix(bare, "/") && !strings.HasSuffix(rel, "/") { + rel += "/" + } + parts := strings.Split(rel, "/") + for i := range parts { + parts[i] = url.PathEscape(parts[i]) + } + return strings.Join(parts, "/") + suffix, nil + } + changes := []textReplacement{} + // Node positions select the exact scalar token; aliases, folded scalars and + // unfamiliar derived_from shapes fail instead of rewriting a YAML subtree. + if nodes, ok := d.keys["derived_from"]; ok { + var visit func(*yaml.Node) error + visit = func(node *yaml.Node) error { + if node.Anchor != "" || node.Style&yaml.TaggedStyle != 0 { + return errors.New("dependency anchors and tags are unsupported") + } + switch node.Kind { + case yaml.SequenceNode: + for _, child := range node.Content { + if child.Kind != yaml.ScalarNode && child.Kind != yaml.MappingNode { + return errors.New("unsupported nested dependency sequence") + } + if e := visit(child); e != nil { + return e + } + } + case yaml.MappingNode: + found := false + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value != "path" && node.Content[i].Value != "fit" { + return errors.New("unsupported dependency field") + } + if node.Content[i].Value == "path" { + if node.Content[i+1].Kind != yaml.ScalarNode { + return errors.New("dependency path must be a string") + } + if found { + return errors.New("duplicate dependency path") + } + found = true + if e := visit(node.Content[i+1]); e != nil { + return e + } + } + } + if !found { + return errors.New("dependency mapping lacks path") + } + case yaml.ScalarNode: + if node.Tag != "!!str" || node.Anchor != "" || node.Style&(yaml.TaggedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { + return errors.New("unsupported dependency scalar") + } + value, e := relocate(node.Value) + if e != nil { + return e + } + if value == node.Value { + return nil + } + start, end, e := yamlScalarRange(data, node) + if e != nil { + return e + } + b, _ := json.Marshal(value) + changes = append(changes, textReplacement{start, end, b}) + default: + return errors.New("unsupported dependency alias or node") + } + return nil + } + if err = visit(nodes[1]); err != nil { + return nil, err + } + } + masked := maskMarkdownCode(d.Body) + bodyOffset := len(data) - len(d.Body) + for _, pattern := range []*regexp.Regexp{draftInlineReference, draftReferenceDefinition} { + matches := pattern.FindAllSubmatchIndex(masked, -1) + for _, match := range matches { + start, end := match[2], match[3] + if start < 0 { + start, end = match[4], match[5] + } + ref := string(d.Body[start:end]) + value, e := relocate(ref) + if e != nil { + return nil, e + } + if value != ref { + changes = append(changes, textReplacement{bodyOffset + start, bodyOffset + end, []byte(value)}) + } + for i := match[0]; i < match[1]; i++ { + masked[i] = ' ' + } + } + } + if draftReferenceSyntax.Match(masked) || draftHTML.Match(draftAutolink.ReplaceAll(masked, nil)) { + return nil, errors.New("unsupported template reference syntax") + } + sort.Slice(changes, func(i, j int) bool { return changes[i].start < changes[j].start }) + result := []byte{} + offset := 0 + for _, change := range changes { + if change.start < offset { + return nil, errors.New("overlapping template references") + } + result = append(result, data[offset:change.start]...) + result = append(result, change.value...) + offset = change.end + } + return append(result, data[offset:]...), nil +} +func yamlScalarRange(data []byte, node *yaml.Node) (int, int, error) { + lines := bytes.SplitAfter(data, []byte("\n")) + if node.Line < 1 || node.Line >= len(lines) { + return 0, 0, errors.New("dependency position outside frontmatter") + } + offset := 0 + for _, line := range lines[:node.Line] { + offset += len(line) + } + runes := []rune(string(lines[node.Line])) + if node.Column < 1 || node.Column > len(runes) { + return 0, 0, errors.New("invalid dependency column") + } + offset += len(string(runes[:node.Column-1])) + end := offset + if node.Style == 0 { + end += len(node.Value) + if end > len(data) || string(data[offset:end]) != node.Value { + return 0, 0, errors.New("multiline dependency is unsupported") + } + } else { + quote := data[offset] + if quote != '\'' && quote != '"' { + return 0, 0, errors.New("unsupported dependency quoting") + } + end++ + for end < len(data) { + if data[end] == '\n' || data[end] == '\r' { + return 0, 0, errors.New("multiline dependency is unsupported") + } + if quote == '"' && data[end] == '\\' { + end += 2 + continue + } + if data[end] == quote { + if quote == '\'' && end+1 < len(data) && data[end+1] == quote { + end += 2 + continue + } + end++ + break + } + end++ + } + } + if end > len(data) { + return 0, 0, errors.New("unterminated dependency") + } + var value string + if e := yaml.Unmarshal(data[offset:end], &value); e != nil || value != node.Value { + return 0, 0, errors.New("dependency token mismatch") + } + return offset, end, nil +} + +// Masking keeps byte offsets intact, including CRLF. Examples inside code and +// comments are not navigation references and must remain verbatim. +func maskMarkdownCode(data []byte) []byte { + out := append([]byte{}, data...) + fence := byte(0) + fenceLength := 0 + comment := false + offset := 0 + blank := func(start, end int) { + for i := start; i < end; i++ { + if out[i] != '\n' && out[i] != '\r' { + out[i] = ' ' + } + } + } + for _, line := range bytes.SplitAfter(data, []byte("\n")) { + trim := strings.TrimSpace(string(line)) + run := 0 + var ch byte + if len(trim) > 0 { + ch = trim[0] + if ch == '`' || ch == '~' { + for run < len(trim) && trim[run] == ch { + run++ + } + } + } + if fence != 0 { + blank(offset, offset+len(line)) + if ch == fence && run >= fenceLength && strings.TrimSpace(trim[run:]) == "" { + fence = 0 + } + offset += len(line) + continue + } + if !comment && run >= 3 && (ch != '`' || !strings.Contains(trim[run:], "`")) { + fence = ch + fenceLength = run + blank(offset, offset+len(line)) + offset += len(line) + continue + } + if !comment && (bytes.HasPrefix(line, []byte(" ")) || bytes.HasPrefix(line, []byte("\t"))) { + blank(offset, offset+len(line)) + offset += len(line) + continue + } + for i := 0; i < len(line); { + if comment { + end := bytes.Index(line[i:], []byte("-->")) + if end < 0 { + blank(offset+i, offset+len(line)) + break + } + blank(offset+i, offset+i+end+3) + i += end + 3 + comment = false + continue + } + start := bytes.Index(line[i:], []byte("