From 71e3fd430e4650326fdb0f798f6b8baf8ce51618 Mon Sep 17 00:00:00 2001 From: Duncan Parker Date: Tue, 4 Aug 2026 13:37:36 -0400 Subject: [PATCH] Implement qxctl lifecycle profile and observation reports --- README.md | 2 +- knowledge/INTENT.md | 8 +- knowledge/LIFECYCLE.md | 15 +- knowledge/MANIFEST.md | 4 +- knowledge/SKILL.md | 6 +- knowledge/SPEC.md | 6 +- knowledge/schemas/v1/MANIFEST.md | 4 +- .../v1/lifecycle-profile-input.schema.json | 40 + .../schemas/v1/lifecycle-profile.schema.json | 41 + knowledge/skvi/INDEX.md | 277 +++++- knowledge/ssfv/REGISTRY.md | 4 +- .../tests/foundation_test.cpp | 2 + .../knowledge-session-coordinator/FEATURES.md | 21 +- .../knowledge-session-coordinator/INSTALL.md | 2 +- .../knowledge-session-coordinator/INTENT.md | 4 +- .../knowledge-session-coordinator/MANIFEST.md | 4 +- .../knowledge-session-coordinator/SKILL.md | 1 + modules/knowledge-session-coordinator/SPEC.md | 6 +- .../src/lifecycle.cpp | 9 +- .../tests/lifecycle_test.cpp | 26 + tools/qxctl/INTENT.md | 4 +- tools/qxctl/MANIFEST.md | 19 +- tools/qxctl/README.md | 17 +- tools/qxctl/SKILL.md | 5 +- tools/qxctl/cmd/qxctl/cli_compat_test.go | 7 +- tools/qxctl/cmd/qxctl/commands.go | 110 ++- tools/qxctl/cmd/qxctl/lifecycle.go | 768 +++++++++++++++ tools/qxctl/cmd/qxctl/lifecycle_test.go | 123 +++ tools/qxctl/cmd/qxctl/main.go | 6 + tools/qxctl/cmd/qxctl/testdata/help.golden | 6 + .../knowledgelifecycle/observation.go | 674 ++++++++++++++ .../internal/knowledgelifecycle/profile.go | 880 ++++++++++++++++++ .../knowledgelifecycle/profile_test.go | 307 ++++++ .../internal/knowledgelifecycle/scan_unix.go | 301 ++++++ .../knowledgelifecycle/scan_unsupported.go | 19 + .../internal/knowledgelifecycle/state_unix.go | 296 ++++++ .../knowledgelifecycle/state_unsupported.go | 33 + tools/symphony-validator/MANIFEST.md | 2 +- tools/symphony-validator/SPEC.md | 2 +- tools/symphony-validator/src/artifacts.cpp | 4 +- tools/symphony-validator/tests/smoke.sh | 4 +- 41 files changed, 3995 insertions(+), 74 deletions(-) create mode 100644 knowledge/schemas/v1/lifecycle-profile-input.schema.json create mode 100644 knowledge/schemas/v1/lifecycle-profile.schema.json create mode 100644 tools/qxctl/cmd/qxctl/lifecycle.go create mode 100644 tools/qxctl/cmd/qxctl/lifecycle_test.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/observation.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/profile.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/profile_test.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/scan_unix.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/scan_unsupported.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/state_unix.go create mode 100644 tools/qxctl/internal/knowledgelifecycle/state_unsupported.go diff --git a/README.md b/README.md index 7cbcf90..8e0b0b6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The repository contains proposal-only Contract Quad seeds for `node-troll`, `bus ## Current Integration Boundary -SSIAG submits only typed, security-relevant safe metadata to the STAV append authority and never writes ledger files. qxctl authenticates the exact configured SSIAG and STAV endpoints before application exchange and performs no canonical mutation. It implements exact user-default engine selection, durable reconciliation, SSIAG-authorized session operations, and explicit host-event session convergence. For SKVI, SCLV, SACV, SODV, and SSFV, qxctl validates an exact inactive-undocked installation before invoking its bounded local process. The coordinator implements report-only lifecycle planning over complete caller-supplied desired/observed evidence, but qxctl lifecycle grammar, desired-profile persistence, configured-root observation, lifecycle journaling/recovery, package installation/uninstall, activation, Maestro docking, desired-state apply, and canonical apply are not implemented. SSFV coverage is explicitly partial and does not claim every implemented capability has been cataloged. The macOS provider reports metadata only. SACV's canonical registry remains empty: no remote HTTP API, SDK, live playground, or published OpenAPI description is currently claimed. SODV release observation remains caller-supplied: the engine does not contact Git hosts or package providers. +SSIAG submits only typed, security-relevant safe metadata to the STAV append authority and never writes ledger files. qxctl authenticates the exact configured SSIAG and STAV endpoints before application exchange and performs no canonical mutation. It implements exact user-default engine selection, durable reconciliation, SSIAG-authorized session operations, explicit host-event session convergence, protected per-TOPS lifecycle profiles, fixed-layout receipt observation, and fresh report-only lifecycle planning through the exact bound coordinator. For SKVI, SCLV, SACV, SODV, and SSFV, qxctl validates an exact inactive-undocked installation before invoking its bounded local process. Lifecycle boot journaling/recovery, package installation/uninstall, activation, Maestro docking, desired-state apply, and canonical apply are not implemented. SSFV coverage is explicitly partial and does not claim every implemented capability has been cataloged. The macOS provider reports metadata only. SACV's canonical registry remains empty: no remote HTTP API, SDK, live playground, or published OpenAPI description is currently claimed. SODV release observation remains caller-supplied: the engine does not contact Git hosts or package providers. ## Releases and Documentation diff --git a/knowledge/INTENT.md b/knowledge/INTENT.md index 4b4a7be..454e989 100644 --- a/knowledge/INTENT.md +++ b/knowledge/INTENT.md @@ -50,14 +50,14 @@ Module contracts (`MANIFEST.md`, etc.) are distinct domains. SKV maps them but d The checked-in `tools/symphony-validator/` implementation produces deterministic, read-only evidence. It currently checks required Knowledge Vector contract anchors, SKVI structure, SKVI coverage and paths, SCLV record shape and continuity, SACV registry structure, SODV local release-record relationships, and bounded repository doctrine. It does not create canonical truth, inspect external publication state, publish documentation, or remediate files. ### Relationship to qxctl -qxctl is the Go administrative surface for the ratified vector-engine family. `qxctl knowledge ...` owns cross-vector engine binding, worktree reconciliation, authenticated-session administration and explicit host-event convergence, later desired-state lifecycle administration, proposal coordination, and later apply coordination. `qxctl skvi|sclv|sacv|sodv|ssfv ...` owns vector-specific grammar. The implemented user-scope `knowledge engines` surface binds exact inactive-undocked installations into one noncanonical default profile, `knowledge reconcile` administers durable noncanonical worktree contexts through the exact bound coordinator, and `knowledge session transition` composes freshly authorized status/recover/begin/checkpoint/close calls for stable login, refresh, and logout events. qxctl implements these contracts but does not own vector semantics or canonical knowledge truth. +qxctl is the Go administrative surface for the ratified vector-engine family. `qxctl knowledge ...` owns cross-vector engine binding, worktree reconciliation, authenticated-session administration, explicit host-event convergence, desired-profile administration, fixed-layout lifecycle observation, report-only planning invocation, proposal coordination, and later apply coordination. `qxctl skvi|sclv|sacv|sodv|ssfv ...` owns vector-specific grammar. The implemented user-scope `knowledge engines` surface binds exact inactive-undocked installations into one noncanonical default profile, `knowledge reconcile` administers durable noncanonical worktree contexts through the exact bound coordinator, `knowledge session transition` composes freshly authorized calls for stable login/refresh/logout events, and `knowledge lifecycle` maintains protected per-TOPS profiles, inventories configured receipt roots, and requests fresh dynamic reports. qxctl implements these contracts but does not own vector semantics or canonical knowledge truth. ### Vector Engine Foundation Each active application-level vector may have an independently installable, out-of-process C++ engine. Shared authority-free C++ mechanics may live under `libraries/`; a separate C++ coordinator owns authenticated-session and worktree-reconciliation mechanics. Engines inspect, validate, project, and propose within vector-owned contracts. They do not acquire authority merely by generating content. -The implemented `0.1.0-dev` foundation and coordinator slice provides strict local process framing, read-only inspect/snapshot checking, explicit compatibility negotiation, durable user-scope worktree reconciliation, SSIAG-authorized noncanonical authority epochs, and deterministic report-only lifecycle planning over fully supplied desired/observed evidence. Independent C++ slices implement SKVI inspect/check/propose/project, SCLV inspect/check/propose/recover/project with provider-neutral evidence adapters, SACV inspect/check/diff/propose/project with bounded OpenAPI 3.2.0 JSON validation, SODV inspect/check/verify/propose/recover/project, and SSFV inspect/check/diff/propose/graph with content-addressed semantic freshness. qxctl invokes each only from an exact inactive-undocked installation and may record one exact version per role in its protected user-default binding profile. SSFV's three-record partial bootstrap demonstrates root and distributed feature ownership without asserting complete coverage. Binding, reconciliation, authenticated-session coordination, or a lifecycle plan does not alter an install receipt, dock with Maestro, invoke vector semantics, or mutate canonical knowledge. +The implemented `0.1.0-dev` foundation and coordinator slice provides strict local process framing, read-only inspect/snapshot checking, explicit compatibility negotiation, durable user-scope worktree reconciliation, SSIAG-authorized noncanonical authority epochs, and deterministic report-only lifecycle planning over fully supplied desired/observed evidence. Independent C++ slices implement SKVI inspect/check/propose/project, SCLV inspect/check/propose/recover/project with provider-neutral evidence adapters, SACV inspect/check/diff/propose/project with bounded OpenAPI 3.2.0 JSON validation, SODV inspect/check/verify/propose/recover/project, and SSFV inspect/check/diff/propose/graph with content-addressed semantic freshness. qxctl invokes each only from an exact inactive-undocked installation, may record one exact version per role in its protected user-default binding profile, and now supplies the lifecycle coordinator with freshly observed content-addressed receipt evidence from administrator-selected roots. SSFV's three-record partial bootstrap demonstrates root and distributed feature ownership without asserting complete coverage. Binding, reconciliation, authenticated-session coordination, profile persistence, observation, or a lifecycle plan does not alter an install receipt, dock with Maestro, invoke vector semantics, or mutate canonical knowledge. -The common lifecycle schema family now defines desired, observed, report-only plan commands, dependency-driven plans, applied state, durable boot transactions, and immutable receipt-v2 truth. The coordinator dynamically recomputes component action order from explicit readiness in either supported migration direction, while authorization, compare-and-swap, integrity, verification, and audit boundaries remain fixed. Desired-profile persistence, configured-root observation, lifecycle journaling/recovery, qxctl administration, and application remain future implementation gates. +The common lifecycle schema family now defines declarative profile input, protected profiles, desired and observed evidence, report-only plan commands, dependency-driven plans, applied state, durable boot transactions, and immutable receipt-v2 truth. qxctl implements the profile, observation, authorization, and report-administration boundary. The coordinator dynamically recomputes component action order from explicit readiness in either supported migration direction, while authorization, compare-and-swap, integrity, verification, and audit boundaries remain fixed. Lifecycle boot journaling/recovery and action application remain future implementation gates. Initial vector-engine releases are read/query/validate/propose only. Programmatic canonical apply remains disabled until its SSIAG permission verification, expected-state transaction, qxctl safeguard, STAV event, recovery, and negative-test contracts are implemented and verified. `knowledge/SPEC.md` owns the common boundary; each vector Contract Quad owns its domain operations. @@ -91,4 +91,4 @@ SODV governs publication truth. Published documentation is a derived public projection. ### Non-authorization Statement -This canonical surface recognizes SACV and SSFV governance and the common lifecycle contract family but authorizes no endpoint, lifecycle mutation runtime, or additional feature record by itself. It authorizes the bounded vector-engine architecture, the implemented read-only/proposal development slices, the report-only coordinator planner, the ratified three-record SSFV partial bootstrap, derived projections, and qxctl grammar defined by `knowledge/MANIFEST.md` and `knowledge/SPEC.md`. It does not authorize lifecycle observation/persistence/apply, canonical apply, an additional SSFV feature record, a repository-completeness claim, a network API, Mintlify configuration, NotebookLM automation, general publication pipeline, database authority, direct STAV mutation, hot/warm-path participation, or any capability outside a vector's own Contract Quad. +This canonical surface recognizes SACV and SSFV governance and the common lifecycle contract family but authorizes no endpoint, lifecycle action execution, or additional feature record by itself. It authorizes the bounded vector-engine architecture, the implemented read-only/proposal development slices, protected lifecycle profile/observation/report administration, the report-only coordinator planner, the ratified three-record SSFV partial bootstrap, derived projections, and qxctl grammar defined by `knowledge/MANIFEST.md` and `knowledge/SPEC.md`. It does not authorize lifecycle boot journaling or apply, canonical apply, an additional SSFV feature record, a repository-completeness claim, a network API, Mintlify configuration, NotebookLM automation, general publication pipeline, database authority, direct STAV mutation, hot/warm-path participation, or any capability outside a vector's own Contract Quad. diff --git a/knowledge/LIFECYCLE.md b/knowledge/LIFECYCLE.md index 0ccb267..622be23 100644 --- a/knowledge/LIFECYCLE.md +++ b/knowledge/LIFECYCLE.md @@ -2,7 +2,7 @@ ## Status -This document records the Architect-ratified topology for explicit authenticated session transitions and the cross-vector desired-state lifecycle. The session-transition surface described below is implemented by qxctl over the existing SSIAG-authorized coordinator operations. Canonical desired-state, observation, plan-command, dependency-driven plan, applied-state, boot-journal/head, and immutable receipt v2 schemas are present. The C++ coordinator implements deterministic report-only planning over complete caller-supplied desired and observed evidence. Desired-profile persistence, configured-root observation, qxctl lifecycle invocation, durable boot journaling/recovery, lifecycle application, installation, uninstall, activation, and live Maestro docking remain planned gates. +This document records the Architect-ratified topology for explicit authenticated session transitions and the cross-vector desired-state lifecycle. The session-transition surface described below is implemented by qxctl over the existing SSIAG-authorized coordinator operations. Canonical profile-input, protected profile, desired-state, observation, plan-command, dependency-driven plan, applied-state, boot-journal/head, and immutable receipt v2 schemas are present. qxctl implements SSIAG-authorized desired-profile administration, fixed-layout configured-root observation, and fresh report invocation through one exact bound C++ coordinator. The coordinator implements deterministic report-only dependency planning. Durable boot journaling/recovery, lifecycle application, package installation/uninstall, activation, and live Maestro docking remain planned gates. ## Purpose @@ -78,8 +78,9 @@ New qxctl versions must dual-read supported v1 and v2 evidence. They must not re ## Desired, Observed, and Applied State -Three states remain separate: +Four state surfaces remain separate: +- **profile input**: bounded declarative caller intent accepted by qxctl without caller-manufactured generations or digests; - **desired state**: protected noncanonical administrator intent managed through qxctl; - **observed state**: a disposable content-addressed inventory of validated receipts, files, bindings, and later Maestro presence; - **applied state**: durable noncanonical evidence of the last exact lifecycle plan successfully committed. @@ -127,13 +128,13 @@ The implemented report-only planner additionally binds each dock action to one e A stable lifecycle observation key binds at least: - desired-state digest; -- observed-inventory digest; +- stable observed-inventory digest, computed from the validated observation while excluding the document-only `observed_at` and `observation_digest` fields; - binding-registry digest when present; - supported lifecycle protocol/capability set; - TOPS and profile identity; - normalized platform-compatibility digest covering the operating-system/kernel ABI, architecture, qxctl/coordinator identities, and configured provider availability without host secrets or volatile boot identity. -Applied state stores the last successfully stabilized observation key. The prior applied-state digest is the transaction's compare-and-swap anchor, not an input to the stable observation key; otherwise every successful applied-state write would invalidate its own next boot. A transaction identity binds the new observation key, the exact prior applied-state digest, and one stable operation ID. An observation key equal to the last stabilized key is an idempotent no-op. A changed key starts or resumes one durable boot transaction. The sequence is: +The complete observation document remains content-addressed, so refreshed collection time changes its evidence digest. Time alone does not change the stable inventory digest, transaction identity, ready set, or semantic action identities. Applied state stores the last successfully stabilized observation key. The prior applied-state digest is the transaction's compare-and-swap anchor, not an input to the stable observation key; otherwise every successful applied-state write would invalidate its own next boot. A transaction identity binds the new observation key and the exact prior applied-state digest. An observation key equal to the last stabilized key is an idempotent no-op. A changed key starts or resumes one durable boot transaction. The sequence is: 1. acquire the protected lifecycle lock without sharing any hot/warm lock; 2. read the desired profile and prior journal without following links; @@ -148,7 +149,9 @@ Applied state stores the last successfully stabilized observation key. The prior Steps 1 through 10 are ordered safety phases. Within the action phase, component work follows the dependency-driven ready-set contract above and may be replanned around a localized blocker without bypassing authorization, compare-and-swap, integrity, verification, or audit. -The administrator-selectable boot mode is `report` or `apply-compatible`. `report` is the default until authenticated lifecycle mutation is implemented. Disabling automatic application never disables parsing bounds, ownership checks, receipt integrity, expected-state compare-and-swap, critical-extension blocking, or secret exclusion. +The administrator-selectable boot mode is `report` or `apply-compatible`. `report` is the default until authenticated lifecycle mutation is implemented. qxctl currently persists either intent but always invokes the report-only coordinator and states that apply is unavailable. Disabling automatic application never disables parsing bounds, ownership checks, receipt integrity, expected-state compare-and-swap, critical-extension blocking, or secret exclusion. + +Desired profiles are protected per TOPS beneath `${XDG_STATE_HOME:-~/.local/state}/symphony//qxctl/knowledge/lifecycle/profiles/`, or beneath an explicit qxctl-selected state root. Mutations use an exact expected profile digest, semantic retry is a stable no-op, generations and predecessor digests are qxctl-generated, and durable writes use a persistent no-follow lock plus atomic replacement and directory synchronization. Profile roots may be changed through qxctl; an absent future installation root is retained as empty observed evidence rather than created or treated as a scan failure. Existing roots are no-follow trusted directories, and observation scans only `/share/symphony/receipts///install-receipt.json`, never arbitrary executable discovery. Known receipt v1 packages use exact existing adapters. Receipt v2 packages are checked against their content-addressed owned files, entry points, capabilities, receptors, and platform requirements. Unsupported, unreadable, and ambiguous packages remain explicit preserved unknown evidence. If the desired profile is absent, unsupported, or critically extended, first boot reports protected read-only state and preserves every installed package and binding. If an operating-system update changes only the platform-compatibility digest, the same planner reevaluates all selected components against their declared platform requirements without assuming reinstall, upgrade, or removal. Host boot integration, its install/uninstall lifecycle, and the `knowledge lifecycle boot` grammar remain future reviewed implementation surfaces. @@ -201,7 +204,7 @@ The engine implementation remains Linux-first with macOS development support. Na 1. Implement explicit idempotent qxctl login/refresh/logout transition composition over the existing authenticated coordinator operations. 2. Add the generic desired-state, observation, dependency-driven plan, applied-state, boot-journal/head, and immutable content-addressed receipt v2 schemas while retaining strict v1 adapters. **Completed.** 3. Implement the C++ coordinator dependency scheduler, two-way compatibility negotiation, and deterministic report-only planner over caller-supplied evidence. **Completed.** Configured-root observation remains in step 4. -4. Implement qxctl desired-profile administration and configured-root inventory with caller-neutral SSIAG authorization. +4. Implement qxctl desired-profile administration and configured-root inventory with caller-neutral SSIAG authorization. **Completed.** qxctl also performs fresh observation and exact bound-coordinator invocation for report-only planning; no plan is persisted. 5. Implement durable C++ boot journaling, report mode, bounded replanning recovery, and installation-change diagnosis. 6. Implement separately gated `apply-compatible`, exact package lifecycle actions, and forward/inverse rollback proof. 7. Add Maestro docking only after its receptor and presence contracts are ratified. diff --git a/knowledge/MANIFEST.md b/knowledge/MANIFEST.md index 3e00853..9fe5eab 100644 --- a/knowledge/MANIFEST.md +++ b/knowledge/MANIFEST.md @@ -50,9 +50,9 @@ Symphony is Linux-first. Native Windows engines are not planned. Windows operati ## Current Delivery State -The shared C++ foundation, the coordinator's user-scope reconciliation, authenticated-session, and report-only lifecycle-planning slices, and the independently installable SKVI, SCLV, SACV, SODV, and SSFV engines are implemented at `0.1.0-dev`. The coordinator provides compatibility negotiation and content-addressed begin/status/checkpoint/close/recover over separate durable per-worktree reconciliation journals and per-TOPS/subject/repository authority-epoch journals. Its report-only lifecycle operation validates complete caller-supplied desired/observed evidence and emits a deterministic forward/inverse dependency-ready-set plan with exact target/receptor identities, isolated cycles/blockers, and disabled apply. qxctl authenticates the local SSIAG endpoint, requests an exact caller-neutral authorization decision for each session operation, revalidates the exact coordinator binding, and invokes that coordinator. SSIAG derives the subject from kernel peer credentials, evaluates explicit exact grants with a deny default, and returns short-lived non-transferable capability evidence only after a committed STAV append. This evidence authorizes protected noncanonical session coordination; it is neither a bearer credential nor canonical apply authority. SKVI implements bounded structural inspect/check, caller-declared proposal, and disposable projection operations. SCLV implements provider-neutral v1/v2/v3 ledger checking, v3 proposals, non-mutating recovery reconciliation, disposable projection, and local-Git/air-gapped evidence normalization. SACV implements bounded OpenAPI 3.2.0 JSON checks, deterministic compatibility diffs, caller-declared registry proposals, and disposable registry-conformance inventories; YAML fails closed until its parser gate. SODV implements local append-only v1/v2 release-ledger checks, caller-supplied external-state verification, provider-neutral v2 release-record proposals, non-mutating interrupted-session recovery, and disposable release inventories without network or publication authority. SSFV implements structural checks, content-addressed semantic snapshots and freshness modes, baseline-versus-live diffs, caller-declared multi-file proposals, and disposable semantic graphs without feature-worthiness or mutation authority. Its first partial bootstrap records exactly the platform capability, shared engine foundation, and coordinator foundation; it does not establish repository-wide catalog completeness. qxctl invokes each implemented engine version only after validating its inactive undocked receipt and owned files. qxctl also manages one protected user-scope `default` binding profile and uses the exact coordinator binding for reconciliation and authenticated sessions. qxctl lifecycle grammar/evidence collection, desired-profile persistence, lifecycle journaling/action execution, observers, coordinator-to-vector invocation, repository/system/TOPS binding profiles, safeguard administration, programmatic canonical apply, live Maestro docking, external package-manager publication, additional SSFV feature records, and SACV-governed HTTP API documents remain unimplemented or separately gated. +The shared C++ foundation, the coordinator's user-scope reconciliation, authenticated-session, and report-only lifecycle-planning slices, and the independently installable SKVI, SCLV, SACV, SODV, and SSFV engines are implemented at `0.1.0-dev`. The coordinator provides compatibility negotiation and content-addressed begin/status/checkpoint/close/recover over separate durable per-worktree reconciliation journals and per-TOPS/subject/repository authority-epoch journals. Its report-only lifecycle operation validates complete caller-supplied desired/observed evidence and emits a deterministic forward/inverse dependency-ready-set plan with exact target/receptor identities, isolated cycles/blockers, and disabled apply. qxctl authenticates the local SSIAG endpoint, requests exact caller-neutral authorization decisions, maintains protected per-TOPS desired profiles with compare-and-swap and durable replacement, observes only fixed receipt layouts under configured roots, preserves unsupported packages as unknown evidence, and invokes one exact bound coordinator for a fresh dynamic report. Collection time changes the observation document digest but is excluded from the stable inventory key, so a timestamp-only refresh cannot restart a transaction or renumber semantic actions. SSIAG derives the subject from kernel peer credentials, evaluates explicit exact grants with a deny default, and returns short-lived non-transferable capability evidence only after a committed STAV append. This evidence authorizes only its exact protected noncanonical operation; it is neither a bearer credential nor canonical apply authority. SKVI implements bounded structural inspect/check, caller-declared proposal, and disposable projection operations. SCLV implements provider-neutral v1/v2/v3 ledger checking, v3 proposals, non-mutating recovery reconciliation, disposable projection, and local-Git/air-gapped evidence normalization. SACV implements bounded OpenAPI 3.2.0 JSON checks, deterministic compatibility diffs, caller-declared registry proposals, and disposable registry-conformance inventories; YAML fails closed until its parser gate. SODV implements local append-only v1/v2 release-ledger checks, caller-supplied external-state verification, provider-neutral v2 release-record proposals, non-mutating interrupted-session recovery, and disposable release inventories without network or publication authority. SSFV implements structural checks, content-addressed semantic snapshots and freshness modes, baseline-versus-live diffs, caller-declared multi-file proposals, and disposable semantic graphs without feature-worthiness or mutation authority. Its first partial bootstrap records exactly the platform capability, shared engine foundation, and coordinator foundation; it does not establish repository-wide catalog completeness. qxctl invokes each implemented engine version only after validating its inactive undocked receipt and owned files. Durable lifecycle boot journals, action execution, installation/uninstall, activation, coordinator-to-vector invocation, repository/system/TOPS engine-binding profiles, safeguard administration, programmatic canonical apply, live Maestro docking, external package-manager publication, additional SSFV feature records, and SACV-governed HTTP API documents remain unimplemented or separately gated. -The twenty-three exact common v1 schemas under `knowledge/schemas/v1/` govern process requests, process responses, descriptors, install receipts, user-default bindings, reconciliation and authenticated-session commands/state/results, explicit session-transition results, immutable proposals, normalized provider evidence, and the desired/observed/plan-command/plan/applied/boot-journal lifecycle family. The exact common receipt v2 schema under `knowledge/schemas/v2/` provides immutable content-addressed package ownership, entry-point, capability, receptor, and platform evidence without mutable activation or docking state. Its dynamic plan contract uses a dependency-ready-set scheduler with forward/inverse action relationships while preserving ordered safety phases. The coordinator implements only deterministic planning over supplied evidence; lifecycle persistence, configured-root observation, qxctl integration, apply, and Maestro docking remain unimplemented. Three SSIAG-specific authorization v1 schemas govern requests, decisions, and non-transferable capabilities. Four SKVI-specific v1 schemas, five SCLV-specific v3 schemas, six SACV-specific v1 schemas, eight SODV-specific operational schemas, and eighteen SSFV-specific v1/v2 schemas govern their vector payloads and results. Installed coordinator, SKVI, SCLV, SACV, SODV, and SSFV packages report `installed_undocked`, create no active alias, and declare no default receptor until a receptor contract is separately selected. A qxctl binding, reconciliation context, authenticated-session journal, or lifecycle schema does not alter those receipts. +The twenty-five exact common v1 schemas under `knowledge/schemas/v1/` govern process requests, process responses, descriptors, install receipts, user-default bindings, reconciliation and authenticated-session commands/state/results, explicit session-transition results, immutable proposals, normalized provider evidence, protected lifecycle profile input/state, and the desired/observed/plan-command/plan/applied/boot-journal lifecycle family. The exact common receipt v2 schema under `knowledge/schemas/v2/` provides immutable content-addressed package ownership, entry-point, capability, receptor, and platform evidence without mutable activation or docking state. Its dynamic plan contract uses a dependency-ready-set scheduler with forward/inverse action relationships while preserving ordered safety phases. qxctl implements profile persistence, configured-root observation, and report invocation; the coordinator implements deterministic planning over the supplied evidence. Lifecycle boot-journal persistence, recovery, apply, and Maestro docking remain unimplemented. Three SSIAG-specific authorization v1 schemas govern requests, decisions, and non-transferable capabilities. Four SKVI-specific v1 schemas, five SCLV-specific v3 schemas, six SACV-specific v1 schemas, eight SODV-specific operational schemas, and eighteen SSFV-specific v1/v2 schemas govern their vector payloads and results. Installed coordinator, SKVI, SCLV, SACV, SODV, and SSFV packages report `installed_undocked`, create no active alias, and declare no default receptor until a receptor contract is separately selected. A qxctl binding, reconciliation context, authenticated-session journal, or lifecycle schema does not alter those receipts. ## Non-Authorization Statement diff --git a/knowledge/SKILL.md b/knowledge/SKILL.md index 486b7be..666fb23 100644 --- a/knowledge/SKILL.md +++ b/knowledge/SKILL.md @@ -30,11 +30,13 @@ After this contract transition is merged, authorized implementation work may: - expose implemented proposal/read operations through qxctl; - prove independent install/uninstall without silently docking or mutating canonical files. -The implemented `0.1.0-dev` foundation supports direct coordinator `inspect`, explicit-path read-only `check`, durable reconciliation, SSIAG-authorized noncanonical authenticated sessions, and report-only `lifecycle_plan` over fully supplied desired/observed evidence, plus independently installed SKVI `inspect`, `check`, caller-declared `propose`, and disposable `project`; SCLV `inspect`, `check`, provider-neutral `propose`, non-mutating `recover`, and disposable `project`; SACV `inspect`, `check`, `diff`, caller-declared `propose`, and disposable `project`; SODV `inspect`, `check`, `verify`, `propose`, `recover`, and `project`; and SSFV `inspect`, `check`, `diff`, `propose`, and `graph`. qxctl validates each exact receipt, owned paths, process identity, deadline, and response digest before presenting implemented qxctl results; qxctl lifecycle invocation is not yet implemented. Session success additionally proves that one fresh exact SSIAG decision was accepted for the noncanonical operation. A lifecycle report does not prove observation collection, persistence, activation, docking, ratification, canonical endpoint publication, or apply authority. +The implemented `0.1.0-dev` foundation supports direct coordinator `inspect`, explicit-path read-only `check`, durable reconciliation, SSIAG-authorized noncanonical authenticated sessions, and report-only `lifecycle_plan` over fully supplied desired/observed evidence, plus independently installed SKVI `inspect`, `check`, caller-declared `propose`, and disposable `project`; SCLV `inspect`, `check`, provider-neutral `propose`, non-mutating `recover`, and disposable `project`; SACV `inspect`, `check`, `diff`, caller-declared `propose`, and disposable `project`; SODV `inspect`, `check`, `verify`, `propose`, `recover`, and `project`; and SSFV `inspect`, `check`, `diff`, `propose`, and `graph`. qxctl validates each exact receipt, owned path, process identity, deadline, and response digest. It also implements protected per-TOPS lifecycle profiles, fixed-layout receipt observation, fresh exact SSIAG authorization, and report-only coordinator invocation. A lifecycle report proves collection and validation of its bound observation, but does not prove action persistence, activation, docking, ratification, canonical endpoint publication, or apply authority. Use `qxctl knowledge session transition --event login|refresh|logout --event-id ID` only when an explicit host lifecycle integration supplies a stable event identity. Safe retries reuse the same event ID. Add `--recover` only when discovery recovery from damaged local session evidence is intended; it does not recover denial, incompatible critical state, or ambiguity. Symphony does not install a login hook, watcher, or boot unit through this command. -For modular installation planning, read `knowledge/LIFECYCLE.md` with this Contract Quad and the exact common lifecycle schemas. Preserve binding registry v1 and receipt v1 evidence exactly. Treat desired, observed, planned, applied, and boot-journal state as separate noncanonical evidence. A new or missing module is a plan input, not permission to execute, remove, upgrade, downgrade, bind, or dock it. The coordinator planner consumes complete digest-bound desired/observed evidence; it does not collect that evidence. Derive component action order only from the explicit dependency ready set; preserve blockers, replan only after verified evidence changes, bind dock actions to exact receptor identities, and never reorder the enclosing safety phases. +For modular installation planning, read `knowledge/LIFECYCLE.md` with this Contract Quad and the exact common lifecycle schemas. Preserve binding registry v1 and receipt v1 evidence exactly. Treat profile input, desired, observed, planned, applied, and boot-journal state as separate noncanonical evidence. A new or missing module is a plan input, not permission to execute, remove, upgrade, downgrade, bind, or dock it. Use qxctl to generate protected profile generations/digests and to observe configured receipt roots; do not hand-edit protected state. The coordinator consumes complete digest-bound desired/observed evidence and derives component action order only from the explicit dependency ready set. Preserve blockers, replan only after verified evidence changes, bind dock actions to exact receptor identities, and never reorder the enclosing safety phases. + +Use `qxctl knowledge lifecycle profile set --tops-id UUID --input FILE --expected-profile-digest absent|DIGEST` for exact profile compare-and-swap. Use `observe` for disposable fixed-layout receipt evidence and `report` for a fresh re-observe plus exact bound-coordinator plan. Treat `apply-compatible` as stored future intent only: the current path reports and never executes an action. A timestamp-only observation refresh must change document evidence without changing its stable inventory key, transaction, or semantic action identities. The implemented `qxctl knowledge engines list|inspect|doctor|bind|unbind` surface manages only the protected user-scope `default` binding profile. Supply `absent` for the first expected registry state or the exact digest reported by `list` for later mutations. A bind selects exact content for later reconciliation; it does not install, invoke, activate, dock, authenticate, authorize, or apply. diff --git a/knowledge/SPEC.md b/knowledge/SPEC.md index 7cc1cbc..5eac64f 100644 --- a/knowledge/SPEC.md +++ b/knowledge/SPEC.md @@ -161,6 +161,8 @@ qxctl knowledge engines ... qxctl knowledge reconcile compatibility|begin|status|checkpoint|close|recover ... qxctl knowledge session begin|status|checkpoint|close|recover ... qxctl knowledge session transition --event login|refresh|logout --event-id ID ... +qxctl knowledge lifecycle profile list|show|set|remove ... +qxctl knowledge lifecycle observe|report ... qxctl knowledge proposals ... qxctl knowledge apply ... # reserved; disabled until the apply gate passes ``` @@ -183,6 +185,8 @@ The implemented user-scope `qxctl knowledge reconcile ...` surface resolves and The implemented user-scope `qxctl knowledge session ...` surface revalidates the exact coordinator binding, obtains a fresh exact SSIAG decision for every underlying operation, and invokes the coordinator with the common session command. `begin` requires `absent` or the exact digest of a closed predecessor; mutations require a stable operation ID and exact current digest; explicit discovery recovery is permitted only when one unique forward state is provable. `status` is read-only. `transition` performs only the explicit idempotent composition described above and returns a digest-bound noncanonical transition result. These surfaces establish and recover noncanonical authority-epoch evidence only. They do not activate a receipt, invoke vector semantics, mutate canonical knowledge, administer policy/safeguards, use a credential provider, or dock with Maestro. +The implemented `qxctl knowledge lifecycle profile list|show|set|remove` surface maintains protected noncanonical per-TOPS desired profiles beneath the selected state root. Set/remove require exact expected profile state; qxctl generates linked generations and content digests, serializes through a no-follow lock, and commits durable same-directory replacement. `observe` scans only fixed receipt paths under explicit or profile-selected roots, validates known v1 packages through exact adapters and v2 packages through content-addressed ownership evidence, and preserves unsupported or invalid packages as explicit unknown evidence. `report` re-reads the profile, re-observes the roots, obtains fresh SSIAG authorization bound to the exact evidence, revalidates one exact bound coordinator, and validates its report-only plan before presentation. Collection timestamps remain in document evidence but are excluded from the stable inventory key. No lifecycle plan is persisted, no action is executed, and `apply-compatible` profile intent still produces report-only behavior. + ## Authority and Apply Gate Initial releases are inspect, query, check, validate, diff, project, and propose only as permitted by each vector. Programmatic canonical apply is disabled until all of the following are implemented and verified together: @@ -275,4 +279,4 @@ qxctl implements a protected user-scope `default` engine binding registry beneat ## Non-Authorization Statement -This specification does not claim implementation beyond the explicitly identified foundation/coordinator reconciliation and authenticated-session slices, explicit qxctl session transitions, SKVI/SCLV/SACV/SODV/SSFV slices, exact three-record SSFV partial bootstrap, user-default binding registry, and canonical lifecycle/receipt schemas; claim lifecycle persistence, observation, planning, recovery, or apply runtime; enable desired-state or first-boot lifecycle apply or canonical apply; authorize another feature record or complete-catalog claim; authorize an external package coordinate; create an HTTP surface; publish a release artifact; permit direct ledger mutation; or activate Maestro. +This specification does not claim implementation beyond the explicitly identified foundation/coordinator reconciliation, authenticated-session, and report-only lifecycle-planning slices; explicit qxctl session transitions and lifecycle profile/observation/report administration; SKVI/SCLV/SACV/SODV/SSFV slices; exact three-record SSFV partial bootstrap; user-default binding registry; and canonical lifecycle/receipt schemas. It does not claim lifecycle boot-journal persistence/recovery or action execution; enable desired-state or first-boot lifecycle apply or canonical apply; authorize another feature record or complete-catalog claim; authorize an external package coordinate; create an HTTP surface; publish a release artifact; permit direct ledger mutation; or activate Maestro. diff --git a/knowledge/schemas/v1/MANIFEST.md b/knowledge/schemas/v1/MANIFEST.md index f3a4368..027bb1f 100644 --- a/knowledge/schemas/v1/MANIFEST.md +++ b/knowledge/schemas/v1/MANIFEST.md @@ -22,6 +22,8 @@ These exact JSON Schema files are canonical common process and lifecycle contrac - `session-transition-result.schema.json`: qxctl's digest-bound noncanonical result for one explicit idempotent login, refresh, or logout convergence event. - `proposal.schema.json`: provider-neutral immutable proposal envelope and vector-neutral authority boundary. Its explicit `engine_decided_domain_truth: false` assertion prevents any engine from converting validation into ownership, membership, ratification, publication, or other semantic authority. - `provider-evidence.schema.json`: bounded provider-neutral revision, change-request, and ratification evidence normalized by separately discoverable adapters. +- `lifecycle-profile-input.schema.json`: bounded declarative profile intent accepted by qxctl without caller-authored generations, predecessor links, or generated digests. +- `lifecycle-profile.schema.json`: protected per-TOPS selected-root and desired-state profile with exact generation, predecessor, canonical-false, and content-digest evidence. - `lifecycle-desired-state.schema.json`: protected noncanonical exact component intent, dependency, compatibility, activation, and docking selection. - `lifecycle-observation.schema.json`: disposable bounded inventory of configured roots, platforms, packages, component state, integrity, capabilities, and unknown preserved receipts without executable discovery. - `lifecycle-plan-command.schema.json`: exact report-only caller-to-coordinator desired/observation evidence and explicit protocol, receipt-reader, and planner-capability declaration. @@ -34,4 +36,4 @@ All schemas use JSON Schema Draft 2020-12, close every common-governed object wi ## Boundary -The binding schema authorizes only explicit user-scope selection among exact validated local installations. Session artifacts preserve SSIAG decision evidence but are not transferable bearer credentials and grant no canonical write authority by possession. The coordinator implements only caller-supplied, report-only lifecycle planning; lifecycle persistence, configured-root observation, recovery, qxctl administration, and apply remain unimplemented. These artifacts do not authorize canonical apply, network access, system/TOPS binding changes, repository-specific overrides, live Maestro docking, or any vector-specific semantic decision. +The binding schema authorizes only explicit user-scope selection among exact validated local installations. Session artifacts preserve SSIAG decision evidence but are not transferable bearer credentials and grant no canonical write authority by possession. qxctl now implements protected desired-profile persistence, fixed-layout configured-root observation, and report-only planner invocation. The coordinator still receives complete caller-supplied evidence and does not collect or persist it. Lifecycle boot journaling/recovery, action execution, and apply remain unimplemented. These artifacts do not authorize canonical apply, network access, system/TOPS engine-binding changes, repository-specific overrides, live Maestro docking, or any vector-specific semantic decision. diff --git a/knowledge/schemas/v1/lifecycle-profile-input.schema.json b/knowledge/schemas/v1/lifecycle-profile-input.schema.json new file mode 100644 index 0000000..1255aeb --- /dev/null +++ b/knowledge/schemas/v1/lifecycle-profile-input.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:symphony:knowledge:lifecycle-profile-input:v1", + "title": "Symphony lifecycle profile input v1", + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", "format_version", "profile_id", "tops_id", "configured_roots", + "boot_mode", "components", "extensions" + ], + "properties": { + "protocol": {"const": "symphony.knowledge.lifecycle-profile-input.v1"}, + "format_version": {"const": 1}, + "profile_id": {"$ref": "#/$defs/token"}, + "tops_id": {"$ref": "#/$defs/token"}, + "configured_roots": { + "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, + "items": {"$ref": "#/$defs/absolutePath"} + }, + "boot_mode": {"enum": ["report", "apply-compatible"]}, + "components": { + "type": "array", "maxItems": 4096, + "items": {"$ref": "urn:symphony:knowledge:lifecycle-desired-state:v1#/$defs/component"} + }, + "extensions": { + "type": "array", "maxItems": 64, + "items": {"$ref": "urn:symphony:knowledge:lifecycle-desired-state:v1#/$defs/extension"} + } + }, + "$defs": { + "token": { + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "absolutePath": { + "type": "string", "minLength": 1, "maxLength": 4096, + "pattern": "^/(?!\\.{1,2}(?:/|$))(?!.*\\/\\.{1,2}(?:/|$))(?!.*//)(?!.*\\\\)(?!.*[\\u0000-\\u001F\\u007F])(?:$|[^/](?:.*[^/])?)$" + } + } +} diff --git a/knowledge/schemas/v1/lifecycle-profile.schema.json b/knowledge/schemas/v1/lifecycle-profile.schema.json new file mode 100644 index 0000000..86fcaa9 --- /dev/null +++ b/knowledge/schemas/v1/lifecycle-profile.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:symphony:knowledge:lifecycle-profile:v1", + "title": "Symphony protected lifecycle profile v1", + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", "format_version", "profile_id", "tops_id", "generation", + "previous_profile_digest", "configured_roots", "boot_mode", "desired_state", + "canonical", "profile_digest" + ], + "properties": { + "protocol": {"const": "symphony.knowledge.lifecycle-profile.v1"}, + "format_version": {"const": 1}, + "profile_id": {"$ref": "#/$defs/token"}, + "tops_id": {"$ref": "#/$defs/token"}, + "generation": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "previous_profile_digest": { + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/digest"}] + }, + "configured_roots": { + "type": "array", "minItems": 1, "maxItems": 64, "uniqueItems": true, + "items": {"$ref": "#/$defs/absolutePath"} + }, + "boot_mode": {"enum": ["report", "apply-compatible"]}, + "desired_state": {"$ref": "urn:symphony:knowledge:lifecycle-desired-state:v1"}, + "canonical": {"const": false}, + "profile_digest": {"$ref": "#/$defs/digest"} + }, + "$defs": { + "token": { + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "absolutePath": { + "type": "string", "minLength": 1, "maxLength": 4096, + "pattern": "^/(?!\\.{1,2}(?:/|$))(?!.*\\/\\.{1,2}(?:/|$))(?!.*//)(?!.*\\\\)(?!.*[\\u0000-\\u001F\\u007F])(?:$|[^/](?:.*[^/])?)$" + } + } +} diff --git a/knowledge/skvi/INDEX.md b/knowledge/skvi/INDEX.md index c68060a..5ef2d5d 100644 --- a/knowledge/skvi/INDEX.md +++ b/knowledge/skvi/INDEX.md @@ -794,6 +794,19 @@ Future validator increments may add separately ratified deterministic checks wit - notes: Upgrades require a new reviewed dependency and release-evidence increment. - status: canonical +#### Knowledge Vector Engine Foundation Conformance Tests +- path: `libraries/knowledge-vector-engine-cpp/tests/foundation_test.cpp` +- title: Knowledge Vector Engine Foundation Conformance Tests +- surface_type: C++26 conformance-test implementation +- truth_role: digest, bounded protocol, path, snapshot, schema identity, and lifecycle-invariant proof +- owner: SKV foundation maintainers +- scope: Verifies authority-free mechanics and the exact common schema identities consumed across installed engines and the coordinator. +- relationships: verifies -> `libraries/knowledge-vector-engine-cpp/SPEC.md`; verifies -> `knowledge/schemas/v1/MANIFEST.md`; verifies -> `knowledge/schemas/v2/MANIFEST.md` +- consumers: foundation/coordinator/vector-engine maintainers, reviewers, release gates +- deferred_projections: portable conformance evidence +- notes: Schema checks establish identity and invariant anchors, not domain authority or runtime activation. +- status: canonical + ### SACV Canonical Knowledge Vector #### SACV INTENT.md @@ -1338,6 +1351,32 @@ Future validator increments may add separately ratified deterministic checks wit - status: canonical - notes: C++26 build contract surface; not a generated projection. +##### Canonical JSON Artifact Allowlist Implementation +- path: `tools/symphony-validator/src/artifacts.cpp` +- title: symphony-validator Exact Canonical JSON Artifact Allowlist +- surface_type: C++26 validator implementation surface +- truth_role: exact path-scoped canonical JSON authorization evidence implementation truth +- owner: validator maintainer +- scope: Recognizes only Architect-ratified JSON schema/fixture paths and rejects prefix- or extension-wide projection authorization. +- relationships: implements -> `tools/symphony-validator/SPEC.md`; governed_by -> `knowledge/SPEC.md`; governed_by -> vector Contract Quads +- consumers: symphony-validator, validator tests, reviewers +- deferred_projections: structured artifact evidence +- notes: Adding a schema requires an explicit contract, path entry, count update, and regression verification. +- status: canonical + +##### symphony-validator Smoke Regression Suite +- path: `tools/symphony-validator/tests/smoke.sh` +- title: symphony-validator Repository and Fixture Smoke Suite +- surface_type: shell conformance-test implementation +- truth_role: current-repository, exact-artifact-count, negative-fixture, and exit-behavior proof +- owner: validator maintainer +- scope: Executes deterministic valid/invalid repository matrices, exact canonical JSON counts, caller-authority checks, and validator build-integrity failures. +- relationships: verifies -> `tools/symphony-validator/SPEC.md`; executes -> `tools/symphony-validator/src/artifacts.cpp` +- consumers: validator maintainers, reviewers, release gates +- deferred_projections: CI/PR-gate integration +- notes: The suite validates behavior and never remediates repository state. +- status: canonical + ### qxctl Tool Contract #### qxctl INTENT.md @@ -1350,7 +1389,7 @@ Future validator increments may add separately ratified deterministic checks wit - relationships: declares -> `tools/qxctl/MANIFEST.md`; depends_on -> `knowledge/SPEC.md`; interprets -> vector and module contracts - consumers: administrators, implementers, reviewers, agentic tools - deferred_projections: command reference and completion metadata -- notes: Implemented SSIAG/STAV, vector-engine, user-default binding, reconciliation, and authenticated-session commands are distinguished from reserved proposal, lifecycle, safeguard, and apply grammar. +- notes: Implemented SSIAG/STAV, vector-engine, user-default binding, reconciliation, authenticated-session, and report-only lifecycle-administration commands are distinguished from reserved proposal, safeguard, lifecycle-action, and apply grammar. - status: canonical #### qxctl MANIFEST.md @@ -1359,7 +1398,7 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: tool manifest - truth_role: command, dependency, installation, and non-authorization contract - owner: qxctl maintainer -- scope: Enumerates operational commands, user-default engine bindings, bound reconciliation/session grammar, reserved knowledge grammar, constrained dependencies, and lifecycle boundaries. +- scope: Enumerates operational commands, user-default engine bindings, bound reconciliation/session grammar, protected lifecycle-profile/observation/report grammar, reserved knowledge grammar, constrained dependencies, and lifecycle boundaries. - relationships: depends_on -> `tools/qxctl/INTENT.md`; depends_on -> `knowledge/SPEC.md`; governs -> `tools/qxctl/cmd/qxctl/` - consumers: qxctl implementers, module/vector maintainers, reviewers, agentic tools - deferred_projections: command registry and module lifecycle evidence @@ -1411,8 +1450,8 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: administrative CLI implementation surface - truth_role: local operation dispatch, process-client invocation, and presentation implementation truth - owner: qxctl maintainers -- scope: Implements current repository, module, SSIAG, STAV, knowledge-engine binding/reconciliation/authenticated-session and explicit session-transition, SKVI, SCLV, SACV, SODV, and shared SSFV administrative operation handlers. -- relationships: implements -> `tools/qxctl/MANIFEST.md`; emits -> `knowledge/schemas/v1/session-transition-result.schema.json`; invokes -> `tools/qxctl/internal/knowledgeengine/client.go`; invokes -> `tools/qxctl/internal/knowledgebinding/registry.go`; invokes -> `tools/qxctl/internal/ssiagclient/client.go` +- scope: Implements current repository, module, SSIAG, STAV, knowledge-engine binding/reconciliation/authenticated-session/session-transition/lifecycle, SKVI, SCLV, SACV, SODV, and shared SSFV administrative operation handlers. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; emits -> `knowledge/schemas/v1/session-transition-result.schema.json`; invokes -> `tools/qxctl/cmd/qxctl/lifecycle.go`; invokes -> `tools/qxctl/internal/knowledgeengine/client.go`; invokes -> `tools/qxctl/internal/knowledgebinding/registry.go`; invokes -> `tools/qxctl/internal/ssiagclient/client.go` - consumers: qxctl executable, tests, maintainers, reviewers - deferred_projections: generated CLI reference and operation evidence - notes: Presentation does not own vector semantics or authorize mutation. @@ -1424,8 +1463,8 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: administrative CLI implementation surface - truth_role: implemented command tree, flag grammar, and failure routing - owner: qxctl maintainers -- scope: Implements current repository, SSIAG, STAV, user-default engine binding, bound-coordinator reconciliation/authenticated-session and explicit session-transition, and exact-installation SKVI/SCLV/SACV/SODV/SSFV command grammar without owning domain semantics. -- relationships: implements -> `tools/qxctl/MANIFEST.md`; invokes -> `tools/qxctl/internal/knowledgeengine/client.go`; invokes -> `tools/qxctl/internal/knowledgebinding/registry.go` +- scope: Implements current repository, SSIAG, STAV, user-default engine binding, bound-coordinator reconciliation/authenticated-session and explicit session-transition, lifecycle profile/observation/report, and exact-installation SKVI/SCLV/SACV/SODV/SSFV command grammar without owning domain semantics. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; invokes -> `tools/qxctl/cmd/qxctl/lifecycle.go`; invokes -> `tools/qxctl/internal/knowledgeengine/client.go`; invokes -> `tools/qxctl/internal/knowledgebinding/registry.go` - consumers: qxctl executable, compatibility tests, maintainers, reviewers - deferred_projections: generated CLI reference documentation - notes: Cobra grammar does not authorize lifecycle activation or canonical apply. @@ -1437,13 +1476,156 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: bounded Go process-client implementation - truth_role: trusted receipt resolution, child-process bounds, and response verification implementation truth - owner: qxctl maintainers -- scope: Resolves exact installed coordinator, SKVI, SCLV, SACV, SODV, and SSFV versions for binding evidence; invokes the bound coordinator for reconciliation/session and five vector engines with an empty environment and hard deadline; and verifies response identity and digest. +- scope: Resolves exact installed coordinator, SKVI, SCLV, SACV, SODV, and SSFV versions for binding and lifecycle evidence; invokes the bound coordinator for reconciliation/session/report-only lifecycle planning and five vector engines with an empty environment and hard deadline; and verifies response identity and digest. - relationships: implements -> `knowledge/SPEC.md`; implements -> `knowledge/schemas/v1/reconciliation-command.schema.json`; implements -> `knowledge/schemas/v1/session-command.schema.json`; implements -> `knowledge/skvi/SPEC.md`; implements -> `knowledge/sclv/SPEC.md`; implements -> `knowledge/sacv/SPEC.md`; implements -> `knowledge/sodv/SPEC.md`; implements -> `knowledge/ssfv/SPEC.md`; called_by -> `tools/qxctl/cmd/qxctl/commands.go` -- consumers: qxctl knowledge-reconciliation/session/SKVI/SCLV/SACV/SODV/SSFV commands, tests, reviewers, future compatible vector clients +- consumers: qxctl knowledge-reconciliation/session/lifecycle/SKVI/SCLV/SACV/SODV/SSFV commands, tests, reviewers, future compatible vector clients - deferred_projections: additional compatible vector clients - notes: It does not install, alter receipt activation, dock, infer membership, grant permission, ratify, mutate canonical knowledge, or apply; coordinator journal mutation is governed separately. - status: canonical +#### qxctl Lifecycle Operation Layer +- path: `tools/qxctl/cmd/qxctl/lifecycle.go` +- title: qxctl Cross-Vector Lifecycle Administration +- surface_type: administrative CLI implementation surface +- truth_role: protected profile, SSIAG authorization, observation, coordinator invocation, plan validation, and presentation implementation truth +- owner: qxctl maintainers +- scope: Implements profile list/show/set/remove, fixed-layout observe, and dynamic report-only planning without action execution. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; implements -> `knowledge/LIFECYCLE.md`; invokes -> `tools/qxctl/internal/knowledgelifecycle/profile.go`; invokes -> `tools/qxctl/internal/knowledgelifecycle/observation.go`; invokes -> `tools/qxctl/internal/knowledgeengine/client.go`; invokes -> `tools/qxctl/internal/ssiagclient/client.go` +- consumers: qxctl executable, lifecycle tests, administrators, reviewers +- deferred_projections: lifecycle boot, recovery, and apply-compatible action grammar +- notes: A stored apply-compatible profile still produces only a report and never authorizes mutation. +- status: canonical + +#### qxctl Lifecycle Profile Model +- path: `tools/qxctl/internal/knowledgelifecycle/profile.go` +- title: qxctl Protected Lifecycle Profile Model +- surface_type: bounded Go lifecycle-state implementation +- truth_role: profile validation, normalization, generation, continuity, compare-and-swap, and digest implementation truth +- owner: qxctl maintainers +- scope: Implements canonical-schema-conformant profile input and protected noncanonical desired-profile state with semantic retry. +- relationships: implements -> `knowledge/schemas/v1/lifecycle-profile-input.schema.json`; implements -> `knowledge/schemas/v1/lifecycle-profile.schema.json`; emits -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; called_by -> `tools/qxctl/cmd/qxctl/lifecycle.go` +- consumers: qxctl lifecycle commands, tests, administrators, reviewers +- deferred_projections: policy profiles and system-scope deployment service integration +- notes: qxctl generates generations and content digests; callers supply intent and exact expected state. +- status: canonical + +#### qxctl Lifecycle Observation Model +- path: `tools/qxctl/internal/knowledgelifecycle/observation.go` +- title: qxctl Lifecycle Observation Model +- surface_type: bounded Go observation implementation +- truth_role: normalized platform, component, package, binding, stable-inventory, and document-digest implementation truth +- owner: qxctl maintainers +- scope: Builds exact disposable lifecycle-observation v1 evidence and separates stable inventory identity from collection-time document evidence. +- relationships: implements -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; called_by -> `tools/qxctl/cmd/qxctl/lifecycle.go`; populated_by -> `tools/qxctl/internal/knowledgelifecycle/scan_unix.go` +- consumers: qxctl lifecycle commands, coordinator planner, tests, reviewers +- deferred_projections: boot observations and applied-state verification +- notes: Excluding observed_at from stable inventory prevents timestamp-only false transactions without weakening document evidence. +- status: canonical + +#### qxctl Unix Lifecycle Receipt Scanner +- path: `tools/qxctl/internal/knowledgelifecycle/scan_unix.go` +- title: qxctl Unix Fixed-Layout Lifecycle Receipt Scanner +- surface_type: platform-specific secure observation implementation +- truth_role: no-follow receipt discovery, v1 adaptation, v2 content-addressed integrity, and unknown-preservation implementation truth +- owner: qxctl maintainers +- scope: Scans only configured Linux/macOS receipt layouts, executes nothing discovered, validates bounded package evidence, and preserves unsupported state. +- relationships: implements -> `knowledge/LIFECYCLE.md`; reads -> `knowledge/schemas/v1/install-receipt.schema.json`; reads -> `knowledge/schemas/v2/install-receipt.schema.json`; called_by -> `tools/qxctl/internal/knowledgelifecycle/observation.go` +- consumers: qxctl lifecycle observation/report, tests, administrators, reviewers +- deferred_projections: additional ratified receipt adapters +- notes: Unknown, invalid, unreadable, or ambiguous packages remain visible and are never silently removed. +- status: canonical + +#### qxctl Unsupported Native Lifecycle Scanner +- path: `tools/qxctl/internal/knowledgelifecycle/scan_unsupported.go` +- title: qxctl Unsupported Native Lifecycle Scanner +- surface_type: platform fail-closed implementation +- truth_role: unsupported-native-operating-system rejection truth +- owner: qxctl maintainers +- scope: Refuses lifecycle receipt observation where the Linux/macOS no-follow scanner contract is unavailable. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; called_by -> `tools/qxctl/internal/knowledgelifecycle/observation.go` +- consumers: qxctl lifecycle commands, tests, reviewers +- deferred_projections: WSL and remote-node administration documentation +- notes: It does not introduce a native Windows lifecycle engine or weaker traversal fallback. +- status: canonical + +#### qxctl Unix Lifecycle Profile State +- path: `tools/qxctl/internal/knowledgelifecycle/state_unix.go` +- title: qxctl Unix Protected Lifecycle Profile State +- surface_type: platform-specific protected local-state implementation +- truth_role: per-TOPS no-follow lock, ownership, atomic replacement, removal, and durability implementation truth +- owner: qxctl maintainers +- scope: Implements Linux/macOS protected profile directories, nonblocking shared/exclusive locking, mode-0600 state, and file/directory durability. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; called_by -> `tools/qxctl/internal/knowledgelifecycle/profile.go` +- consumers: qxctl lifecycle profile commands, tests, reviewers +- deferred_projections: supervised system-scope state administration +- notes: Stored state is noncanonical and contains no credentials or provider payloads. +- status: canonical + +#### qxctl Unsupported Native Lifecycle Profile State +- path: `tools/qxctl/internal/knowledgelifecycle/state_unsupported.go` +- title: qxctl Unsupported Native Lifecycle Profile State +- surface_type: platform fail-closed implementation +- truth_role: unsupported-native-operating-system rejection truth +- owner: qxctl maintainers +- scope: Refuses lifecycle profile persistence where the Linux/macOS protected-state contract is unavailable. +- relationships: implements -> `tools/qxctl/MANIFEST.md`; called_by -> `tools/qxctl/internal/knowledgelifecycle/profile.go` +- consumers: qxctl lifecycle commands, tests, reviewers +- deferred_projections: WSL and remote-node administration documentation +- notes: It does not introduce a native Windows lifecycle state implementation. +- status: canonical + +#### qxctl Lifecycle Conformance Tests +- path: `tools/qxctl/cmd/qxctl/lifecycle_test.go` +- title: qxctl Lifecycle Plan and Grammar Conformance Tests +- surface_type: Go conformance-test implementation +- truth_role: report-plan validation, disabled-apply, dynamic-scheduler, and command-registration proof +- owner: qxctl maintainers +- scope: Verifies exact plan field/digest/set validation, scheduler invariants, invalid evidence rejection, and lifecycle Cobra grammar. +- relationships: verifies -> `tools/qxctl/cmd/qxctl/lifecycle.go`; verifies -> `knowledge/schemas/v1/lifecycle-plan.schema.json` +- consumers: qxctl maintainers, reviewers, release gates +- deferred_projections: lifecycle apply negative tests +- notes: Tests prove report handling only and grant no lifecycle action authority. +- status: canonical + +#### qxctl Lifecycle State and Observation Tests +- path: `tools/qxctl/internal/knowledgelifecycle/profile_test.go` +- title: qxctl Lifecycle Profile and Observation Conformance Tests +- surface_type: Go conformance-test implementation +- truth_role: profile durability/continuity and fixed-layout observation proof +- owner: qxctl maintainers +- scope: Verifies compare-and-swap, semantic retry, identity drift rejection, required-array shape, v1/v2 receipts, content drift, unknown preservation, future absent roots, and timestamp-neutral stable inventory. +- relationships: verifies -> `tools/qxctl/internal/knowledgelifecycle/profile.go`; verifies -> `tools/qxctl/internal/knowledgelifecycle/observation.go`; verifies -> `tools/qxctl/internal/knowledgelifecycle/scan_unix.go` +- consumers: qxctl maintainers, reviewers, release gates +- deferred_projections: crash-injection and system-scope supervision evidence +- notes: Test fixtures contain no credentials and execute no discovered package. +- status: canonical + +#### qxctl CLI Compatibility Tests +- path: `tools/qxctl/cmd/qxctl/cli_compat_test.go` +- title: qxctl Command Compatibility Tests +- surface_type: Go command-compatibility test implementation +- truth_role: stable usage, failure routing, flag precedence, and forbidden-Viper capability proof +- owner: qxctl maintainers +- scope: Verifies exact CLI output and error grammar including lifecycle leaves while preserving constrained configuration behavior. +- relationships: verifies -> `tools/qxctl/cmd/qxctl/commands.go`; verifies -> `tools/qxctl/cmd/qxctl/testdata/help.golden` +- consumers: qxctl maintainers, reviewers, release gates +- deferred_projections: generated shell completions +- notes: Compatibility proof does not convert reserved operations into implemented behavior. +- status: canonical + +#### qxctl Help Golden +- path: `tools/qxctl/cmd/qxctl/testdata/help.golden` +- title: qxctl Exact Help Compatibility Golden +- surface_type: command-output test fixture +- truth_role: exact implemented command-help expectation +- owner: qxctl maintainers +- scope: Records the stable concise usage text verified by the CLI compatibility suite. +- relationships: generated_by -> `tools/qxctl/cmd/qxctl/main.go`; verified_by -> `tools/qxctl/cmd/qxctl/cli_compat_test.go` +- consumers: qxctl tests, maintainers, reviewers +- deferred_projections: public command reference +- notes: The fixture is test evidence and not an independent command authority. +- status: canonical + #### qxctl Knowledge Engine Binding Registry - path: `tools/qxctl/internal/knowledgebinding/registry.go` - title: qxctl Knowledge Engine Binding Registry @@ -1586,7 +1768,7 @@ Future validator increments may add separately ratified deterministic checks wit - relationships: depends_on -> `knowledge/SPEC.md`; depends_on -> `knowledge/ssiag/SPEC.md`; implements -> `knowledge/schemas/v1/engine-process-request.schema.json`; implements -> `knowledge/schemas/v1/engine-process-response.schema.json`; implements -> `knowledge/schemas/v1/reconciliation-command.schema.json`; implements -> `knowledge/schemas/v1/reconciliation-result.schema.json`; persists -> `knowledge/schemas/v1/reconciliation-journal.schema.json`; persists -> `knowledge/schemas/v1/reconciliation-head.schema.json`; implements -> `knowledge/schemas/v1/session-command.schema.json`; implements -> `knowledge/schemas/v1/session-result.schema.json`; persists -> `knowledge/schemas/v1/session-journal.schema.json`; persists -> `knowledge/schemas/v1/session-head.schema.json`; implements -> `knowledge/schemas/v1/lifecycle-plan-command.schema.json`; emits -> `knowledge/schemas/v1/lifecycle-plan.schema.json` - consumers: C++ implementers, qxctl, testers, reviewers - deferred_projections: authenticated-session conformance evidence and apply procedures -- notes: Desired-profile administration, configured-root observation, lifecycle persistence/apply, system/TOPS binding profiles, direct SSIAG/STAV calls, vector invocation, canonical apply, and live Maestro remain unimplemented. +- notes: qxctl now owns desired-profile administration, configured-root observation, SSIAG authorization, and report invocation outside this module; coordinator lifecycle persistence/apply, system/TOPS engine-binding profiles, direct SSIAG/STAV calls, vector invocation, canonical apply, and live Maestro remain unimplemented. - status: canonical #### Knowledge Session Coordinator FEATURES.md @@ -1599,7 +1781,7 @@ Future validator increments may add separately ratified deterministic checks wit - relationships: depends_on -> `knowledge/ssfv/SPEC.md`; depends_on -> `modules/knowledge-session-coordinator/SPEC.md`; declares -> `ssfv:symphony:knowledge-session-coordinator` - consumers: symphony-ssfv, qxctl, coordinator maintainers, reviewers, administrators, agentic tools - deferred_projections: portable SSFV graph, authenticated-session capability lineage, operator documentation -- notes: Records implemented inspect/check/reconciliation, SSIAG-authorized noncanonical session behavior, qxctl's explicit idempotent host-event convergence, and report-only dependency planning; lifecycle persistence/observation/apply and canonical apply remain unimplemented. +- notes: Records implemented inspect/check/reconciliation, SSIAG-authorized noncanonical session behavior, qxctl's explicit host-event and lifecycle profile/observation/report administration, and report-only dependency planning; lifecycle action persistence/apply and canonical apply remain unimplemented. - status: canonical #### Knowledge Session Coordinator CMakeLists.txt @@ -1637,7 +1819,7 @@ Future validator increments may add separately ratified deterministic checks wit - scope: Declares the planner capability description and bounded lifecycle-plan dispatcher. - relationships: implements -> `modules/knowledge-session-coordinator/SPEC.md`; implemented_by -> `modules/knowledge-session-coordinator/src/lifecycle.cpp` - consumers: coordinator dispatcher, tests, reviewers -- deferred_projections: qxctl lifecycle invocation and persistent execution +- deferred_projections: persistent lifecycle execution - notes: The interface grants no lifecycle or canonical mutation authority. - status: canonical @@ -1649,11 +1831,24 @@ Future validator increments may add separately ratified deterministic checks wit - owner: SKV coordinator maintainers - scope: Validates supplied desired/observed lifecycle evidence and emits deterministic forward/inverse plans with exact target identities, localized blockers, cycle isolation, and disabled apply. - relationships: implements -> `modules/knowledge-session-coordinator/SPEC.md`; implements -> `knowledge/schemas/v1/lifecycle-plan-command.schema.json`; reads -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; reads -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; emits -> `knowledge/schemas/v1/lifecycle-plan.schema.json` -- consumers: symphony-knowledge-session, future qxctl lifecycle client, tests, reviewers -- deferred_projections: configured-root observation, desired-profile persistence, boot journal, action execution, Maestro exchange +- consumers: symphony-knowledge-session, qxctl lifecycle client, tests, reviewers +- deferred_projections: boot journal, action execution, Maestro exchange - notes: No filesystem discovery, persistence, authorization, action execution, or receptor contact occurs. - status: canonical +#### Knowledge Session Coordinator Lifecycle Planner Tests +- path: `modules/knowledge-session-coordinator/tests/lifecycle_test.cpp` +- title: Knowledge Session Coordinator Lifecycle Planner Conformance Tests +- surface_type: C++26 conformance-test implementation +- truth_role: deterministic two-way dependency planner, compatibility, blocker, receptor, scale, and stable-inventory proof +- owner: SKV coordinator maintainers +- scope: Verifies forward/inverse selection, dynamic readiness healing, cycle isolation, safety ordering, bounded scale, integrity/compatibility failures, and timestamp-neutral transaction identity. +- relationships: verifies -> `modules/knowledge-session-coordinator/src/lifecycle.cpp`; verifies -> `knowledge/schemas/v1/lifecycle-plan.schema.json` +- consumers: coordinator/qxctl maintainers, reviewers, release gates +- deferred_projections: durable boot-replan and action-execution conformance +- notes: Tests exercise report-only planning and never perform package or Maestro mutation. +- status: canonical + #### Knowledge Session Coordinator Authority-Session Header - path: `modules/knowledge-session-coordinator/src/authority_session.hpp` - title: Knowledge Session Coordinator Authority-Session Interface @@ -1955,7 +2150,7 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: SKV umbrella manifest - truth_role: common vector-engine identity, namespace, installability, and authority boundary - owner: Symphony Knowledge Vector maintainers -- scope: Declares independently installed C++ engines, the implemented shared mechanics/reconciliation/authenticated-session coordinator/SKVI/SCLV/SACV/SODV/SSFV slices, explicit qxctl session transitions, the prospective generic desired-state/first-boot topology, the first partial SSFV catalog, qxctl administration, Linux-first delivery, Maestro readiness, and proposal-only canonical-write state. +- scope: Declares independently installed C++ engines, the implemented shared mechanics/reconciliation/authenticated-session coordinator/SKVI/SCLV/SACV/SODV/SSFV slices, explicit qxctl session transitions and report-only lifecycle administration, the generic desired-state/first-boot topology, the first partial SSFV catalog, Linux-first delivery, Maestro readiness, and proposal-only canonical-write state. - relationships: depends_on -> `knowledge/INTENT.md`; declares -> `knowledge/SPEC.md`; governs -> `libraries/knowledge-vector-engine-cpp/`; governs -> `modules/knowledge-session-coordinator/`; governs -> `modules/skvi-engine/`; governs -> `modules/sclv-engine/`; governs -> future cleared vector-engine module paths - consumers: vector maintainers, engine implementers, qxctl, Maestro planners, reviewers, agentic tools - deferred_projections: engine inventory, install receipts, Maestro presence graph @@ -1972,7 +2167,7 @@ Future validator increments may add separately ratified deterministic checks wit - relationships: depends_on -> `knowledge/MANIFEST.md`; governs -> `knowledge/schemas/v1/MANIFEST.md`; governs -> `knowledge/schemas/v2/MANIFEST.md`; governs -> `libraries/knowledge-vector-engine-cpp/SPEC.md`; governs -> `modules/knowledge-session-coordinator/SPEC.md`; depends_on -> `knowledge/ssiag/SPEC.md`; depends_on -> `knowledge/stav/SPEC.md` - consumers: C++ engine and coordinator implementers, qxctl, SSIAG/STAV integrators, reviewers, agentic tools - deferred_projections: apply/provider/docking schemas, conformance evidence, engine inventory, docking graph -- notes: Twenty-three common v1 schemas and one common v2 schema are canonical; report-only coordinator planning is implemented while lifecycle persistence, observation, qxctl administration, and apply are not, the three-record SSFV bootstrap is partial, and programmatic apply is disabled. +- notes: Twenty-five common v1 schemas and one common v2 schema are canonical; lifecycle profile persistence, configured-root observation, and report-only coordinator invocation are implemented while boot journaling/action persistence and apply are not, the three-record SSFV bootstrap is partial, and programmatic apply is disabled. - status: canonical ##### SKILL.md @@ -1995,10 +2190,10 @@ Future validator increments may add separately ratified deterministic checks wit - truth_role: explicit session-transition and generic desired-state/first-boot topology truth - owner: Symphony Knowledge Vector maintainers - scope: Defines stable host-event convergence, immutable binding-v1 compatibility, generic component identity, receipt-v2 migration, desired/observed/plan/applied separation, dependency-ready-set two-way convergence, evidence-based first boot, durable recovery, module addition/removal behavior, and the Maestro boundary. -- relationships: depends_on -> `knowledge/SPEC.md`; governs -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-plan-command.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-plan.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-applied-state.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-boot-journal.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-boot-head.schema.json`; governs -> `knowledge/schemas/v2/install-receipt.schema.json`; governs -> `tools/qxctl/`; governs -> `modules/knowledge-session-coordinator/`; defers_to -> `knowledge/ssiag/SPEC.md`; defers_to -> `knowledge/stav/SPEC.md` +- relationships: depends_on -> `knowledge/SPEC.md`; governs -> `knowledge/schemas/v1/lifecycle-profile-input.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-profile.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-plan-command.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-plan.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-applied-state.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-boot-journal.schema.json`; governs -> `knowledge/schemas/v1/lifecycle-boot-head.schema.json`; governs -> `knowledge/schemas/v2/install-receipt.schema.json`; governs -> `tools/qxctl/`; governs -> `modules/knowledge-session-coordinator/`; defers_to -> `knowledge/ssiag/SPEC.md`; defers_to -> `knowledge/stav/SPEC.md` - consumers: qxctl and coordinator implementers, module packagers, Maestro planners, administrators, reviewers, agentic tools -- deferred_projections: lifecycle persistence, configured-root observer, qxctl planner integration, apply-compatible actions, docking operations -- notes: The lifecycle schema family and report-only coordinator planner are implemented; generic lifecycle persistence, observation collection, qxctl administration/apply, and host integration remain separate gates. +- deferred_projections: lifecycle boot-journal persistence/recovery, apply-compatible actions, docking operations +- notes: The lifecycle schema family, protected qxctl profile persistence, fixed-layout observation, and report-only coordinator invocation are implemented; action persistence/apply and host integration remain separate gates. - status: canonical ##### Common v1 Schema Manifest @@ -2007,7 +2202,7 @@ Future validator increments may add separately ratified deterministic checks wit - surface_type: common protocol schema manifest - truth_role: canonical inventory and boundary for exact common JSON schemas - owner: Symphony Knowledge Vector maintainers -- scope: Declares twenty-three exact process, descriptor, install-receipt-v1, engine-binding, proposal, provider-evidence, reconciliation/session, and desired/observed/plan-command/plan/applied/boot lifecycle schemas. +- scope: Declares twenty-five exact process, descriptor, install-receipt-v1, engine-binding, proposal, provider-evidence, reconciliation/session, profile-input/profile, and desired/observed/plan-command/plan/applied/boot lifecycle schemas. - relationships: depends_on -> `knowledge/SPEC.md`; governs -> `libraries/knowledge-vector-engine-cpp/SPEC.md`; governs -> `modules/knowledge-session-coordinator/SPEC.md`; governs -> `modules/skvi-engine/SPEC.md`; governs -> `modules/sclv-engine/SPEC.md` - consumers: C++ foundation and engine implementers, qxctl planners, validator, reviewers - deferred_projections: generated schema documentation and conformance evidence @@ -2035,11 +2230,37 @@ Future validator increments may add separately ratified deterministic checks wit - owner: Symphony Knowledge Vector maintainers - scope: Closes exact component/package selection, presence, install scope/root, activation, docking, explicit dependencies, compatibility, extensions, and digest continuity. - relationships: depends_on -> `knowledge/schemas/v1/MANIFEST.md`; governed_by -> `knowledge/LIFECYCLE.md` -- consumers: future qxctl desired-profile administrator, coordinator lifecycle planner, validator, reviewers -- deferred_projections: desired-state persistence and qxctl mutation grammar +- consumers: qxctl desired-profile administrator, coordinator lifecycle planner, validator, reviewers +- deferred_projections: policy-profile administration and applied-state comparison - notes: Desired state expresses intent and carries no mutation authority. - status: canonical +##### Lifecycle Profile Input Schema +- path: `knowledge/schemas/v1/lifecycle-profile-input.schema.json` +- title: Symphony Lifecycle Profile Input v1 +- surface_type: JSON Schema Draft 2020-12 contract +- truth_role: canonical bounded declarative lifecycle-profile intent truth +- owner: Symphony Knowledge Vector maintainers +- scope: Closes caller-supplied TOPS/profile identity, configured roots, boot mode, component intent, dependencies, compatibility, and extensions without generated state fields. +- relationships: depends_on -> `knowledge/schemas/v1/MANIFEST.md`; governed_by -> `knowledge/LIFECYCLE.md`; implemented_by -> `tools/qxctl/internal/knowledgelifecycle/profile.go` +- consumers: qxctl lifecycle profile administration, validators, administrators, reviewers +- deferred_projections: generated profile examples and schema documentation +- notes: Callers declare intent; qxctl generates generation, continuity, and content digests. +- status: canonical + +##### Lifecycle Profile Schema +- path: `knowledge/schemas/v1/lifecycle-profile.schema.json` +- title: Symphony Protected Lifecycle Profile v1 +- surface_type: JSON Schema Draft 2020-12 contract +- truth_role: canonical protected noncanonical profile-state contract +- owner: Symphony Knowledge Vector maintainers +- scope: Closes per-TOPS selected roots, boot mode, linked generation and profile digest, plus the exact generated desired-state document. +- relationships: depends_on -> `knowledge/schemas/v1/lifecycle-profile-input.schema.json`; depends_on -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; governed_by -> `knowledge/LIFECYCLE.md`; implemented_by -> `tools/qxctl/internal/knowledgelifecycle/profile.go` +- consumers: qxctl lifecycle administration/report, validators, administrators, reviewers +- deferred_projections: profile inventory and first-boot selection evidence +- notes: Protected profile state is noncanonical, caller-neutral, and never contains credentials. +- status: canonical + ##### Lifecycle Observation Schema - path: `knowledge/schemas/v1/lifecycle-observation.schema.json` - title: Symphony Lifecycle Observation v1 @@ -2048,8 +2269,8 @@ Future validator increments may add separately ratified deterministic checks wit - owner: Symphony Knowledge Vector maintainers - scope: Closes configured roots, normalized platform compatibility, binding evidence, package integrity, selected component and exact docked-receptor state, capabilities, and unknown preserved receipts. - relationships: depends_on -> `knowledge/schemas/v1/MANIFEST.md`; governed_by -> `knowledge/LIFECYCLE.md`; reads -> `knowledge/schemas/v1/install-receipt.schema.json`; reads -> `knowledge/schemas/v2/install-receipt.schema.json` -- consumers: future qxctl inventory collector, coordinator lifecycle planner, validator, reviewers -- deferred_projections: bounded configured-root inventory implementation +- consumers: qxctl configured-root inventory collector, coordinator lifecycle planner, validator, reviewers +- deferred_projections: boot-journal observation checkpoints and Maestro presence adapters - notes: Observation never executes discovered code and is rebuildable from validated evidence. - status: canonical @@ -2061,8 +2282,8 @@ Future validator increments may add separately ratified deterministic checks wit - owner: Symphony Knowledge Vector maintainers - scope: Closes supplied desired and observation evidence, optional prior applied-state anchor, process/schema/receipt reader versions, and named two-way planner capabilities. - relationships: depends_on -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; depends_on -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; governed_by -> `knowledge/LIFECYCLE.md`; implemented_by -> `modules/knowledge-session-coordinator/src/lifecycle.cpp` -- consumers: coordinator lifecycle planner, future qxctl lifecycle client, conformance tests, validator, reviewers -- deferred_projections: qxctl request assembly and rendered protocol documentation +- consumers: coordinator lifecycle planner, qxctl lifecycle client, conformance tests, validator, reviewers +- deferred_projections: rendered protocol documentation - notes: The command carries evidence and compatibility claims, not authorization or apply permission. - status: canonical @@ -2074,8 +2295,8 @@ Future validator increments may add separately ratified deterministic checks wit - owner: Symphony Knowledge Vector maintainers - scope: Closes dependency-ready-set scheduling, forward/inverse action relationships, stable action IDs, exact target-state and receptor identities, ordered safety phases, critical dependency blockers, noncritical advisories, cycles, bounded plan revisions, and disabled apply. - relationships: depends_on -> `knowledge/schemas/v1/lifecycle-desired-state.schema.json`; depends_on -> `knowledge/schemas/v1/lifecycle-observation.schema.json`; governed_by -> `knowledge/LIFECYCLE.md`; emitted_by -> `modules/knowledge-session-coordinator/src/lifecycle.cpp` -- consumers: C++ coordinator lifecycle planner, future qxctl reporting, validator, reviewers -- deferred_projections: qxctl report presentation and plan visualization +- consumers: C++ coordinator lifecycle planner, qxctl reporting, validator, reviewers +- deferred_projections: plan visualization and durable boot-journal execution - notes: Dynamic action order cannot bypass authorization, integrity, compare-and-swap, verification, or audit. - status: canonical @@ -3382,7 +3603,7 @@ Future validator increments may add separately ratified deterministic checks wit - status: canonical ## Deferred Projections -Unless a surface is explicitly authorized by its Contract Quad, generated indexes, graphs, DuckDB, JSONL, HDF5 outputs, qxctl integrations, validator implementations outside the bounded `tools/symphony-validator/` contract, and publication pipelines remain deferred and are not canonical authority. Projections authorized by `knowledge/SPEC.md` and a vector Contract Quad remain disposable and digest-bound. The indexed STAV JSON Schemas/fixtures, twenty-three common SKV v1 JSON Schemas, one common SKV v2 JSON Schema, three SSIAG authorization JSON Schemas, four SKVI JSON Schemas, five SCLV JSON Schemas, six SACV JSON Schemas, eight SODV operational JSON Schemas, and eighteen SSFV v1/v2 JSON Schemas are Architect-ratified protocol truth, not generated projections. +Unless a surface is explicitly authorized by its Contract Quad, generated indexes, graphs, DuckDB, JSONL, HDF5 outputs, qxctl integrations, validator implementations outside the bounded `tools/symphony-validator/` contract, and publication pipelines remain deferred and are not canonical authority. Projections authorized by `knowledge/SPEC.md` and a vector Contract Quad remain disposable and digest-bound. The indexed STAV JSON Schemas/fixtures, twenty-five common SKV v1 JSON Schemas, one common SKV v2 JSON Schema, three SSIAG authorization JSON Schemas, four SKVI JSON Schemas, five SCLV JSON Schemas, six SACV JSON Schemas, eight SODV operational JSON Schemas, and eighteen SSFV v1/v2 JSON Schemas are Architect-ratified protocol truth, not generated projections. ## Non-Authorized Artifacts This index authorizes none of the following unless an indexed vector Contract Quad and `knowledge/SPEC.md` explicitly permit the bounded derived form: diff --git a/knowledge/ssfv/REGISTRY.md b/knowledge/ssfv/REGISTRY.md index 5864de2..9f028f9 100644 --- a/knowledge/ssfv/REGISTRY.md +++ b/knowledge/ssfv/REGISTRY.md @@ -37,8 +37,8 @@ The literal `None.` beneath `## Canonical Entries` is the only valid empty-regis - source_scope: `modules/knowledge-session-coordinator` - status: `experimental` - parent_feature_id: `ssfv:symphony:platform` -- record_digest: `sha256:d17bba51552f10e4bc7c68ea3264070dd4f82bbb99da2238be6fbc1528ea0ca4` -- notes: First partial bootstrap record for durable reconciliation, SSIAG-authorized authenticated sessions, explicit idempotent qxctl host-event convergence, and report-only dependency-driven lifecycle planning; lifecycle persistence, observation, and apply remain disabled. +- record_digest: `sha256:9b49266cf09d3f223f681a94323fdf185de79b32a0e3b5d3ba0770dffa480c5f` +- notes: First partial bootstrap record for durable reconciliation, SSIAG-authorized authenticated sessions, explicit idempotent qxctl host-event convergence, protected desired-profile and observation administration, and report-only dependency-driven lifecycle planning; lifecycle action persistence and apply remain disabled. - feature_id: `ssfv:symphony:knowledge-vector-engine-foundation` - feature_file: `libraries/knowledge-vector-engine-cpp/FEATURES.md` diff --git a/libraries/knowledge-vector-engine-cpp/tests/foundation_test.cpp b/libraries/knowledge-vector-engine-cpp/tests/foundation_test.cpp index 2af5436..e67ac80 100644 --- a/libraries/knowledge-vector-engine-cpp/tests/foundation_test.cpp +++ b/libraries/knowledge-vector-engine-cpp/tests/foundation_test.cpp @@ -224,6 +224,8 @@ void test_schema_documents(const fs::path& repository_root) { {"knowledge/schemas/v1/session-journal.schema.json", "urn:symphony:knowledge:session-journal:v1"}, {"knowledge/schemas/v1/session-result.schema.json", "urn:symphony:knowledge:session-result:v1"}, {"knowledge/schemas/v1/session-transition-result.schema.json", "urn:symphony:knowledge:session-transition-result:v1"}, + {"knowledge/schemas/v1/lifecycle-profile-input.schema.json", "urn:symphony:knowledge:lifecycle-profile-input:v1"}, + {"knowledge/schemas/v1/lifecycle-profile.schema.json", "urn:symphony:knowledge:lifecycle-profile:v1"}, {"knowledge/schemas/v1/lifecycle-desired-state.schema.json", "urn:symphony:knowledge:lifecycle-desired-state:v1"}, {"knowledge/schemas/v1/lifecycle-observation.schema.json", "urn:symphony:knowledge:lifecycle-observation:v1"}, {"knowledge/schemas/v1/lifecycle-plan-command.schema.json", "urn:symphony:knowledge:lifecycle-plan-command:v1"}, diff --git a/modules/knowledge-session-coordinator/FEATURES.md b/modules/knowledge-session-coordinator/FEATURES.md index 190049b..763b877 100644 --- a/modules/knowledge-session-coordinator/FEATURES.md +++ b/modules/knowledge-session-coordinator/FEATURES.md @@ -28,7 +28,7 @@ }, { "applicability": "applicable", - "reason": "Every authenticated-session operation consumes a fresh exact caller-neutral SSIAG decision derived from kernel peer identity; the coordinator validates its non-transferable capability evidence without deciding authority.", + "reason": "Every authenticated-session and qxctl lifecycle administration operation consumes a fresh exact caller-neutral SSIAG decision derived from kernel peer identity; the coordinator validates session capability evidence without deciding authority, while qxctl binds lifecycle decisions to exact profile or observation evidence.", "reference": "knowledge/ssiag/SPEC.md", "vector": "ssiag" }, @@ -47,15 +47,17 @@ ], "evidence": [ "modules/knowledge-session-coordinator/tests/coordinator_test.cpp verifies reconciliation and authenticated-session lifecycle mutations, exact-state rejection, idempotent replay, context attachment, linked epochs, decision/capability binding, expiry, capability downgrade, damaged-head recovery, extension preservation, critical-state refusal, lock contention, symlink rejection, and isolation.", - "modules/knowledge-session-coordinator/tests/lifecycle_test.cpp verifies normalized deterministic plans, receipt-v1/v2 capability negotiation, forward and inverse exact-version selection, dependency-ready-set healing, critical/noncritical dependency behavior, component-capability blocking, cycle isolation, compatibility and integrity failure, exact receptor replacement, and safe undock/deactivate/select/activate/dock ordering.", + "modules/knowledge-session-coordinator/tests/lifecycle_test.cpp verifies normalized deterministic plans, receipt-v1/v2 capability negotiation, forward and inverse exact-version selection, dependency-ready-set healing, critical/noncritical dependency behavior, component-capability blocking, cycle isolation, compatibility and integrity failure, exact receptor replacement, safe undock/deactivate/select/activate/dock ordering, and timestamp-neutral transaction identity.", "modules/knowledge-session-coordinator/tests/process_smoke.sh verifies deterministic process responses, implemented session capability truth, bounded check output, duplicate-key rejection, and disabled canonical apply.", "modules/knowledge-session-coordinator/CMakeLists.txt builds, tests, installs, receipts, and uninstalls the exact versioned process.", "modules/knowledge-session-coordinator/SPEC.md defines inspect/check plus separate durable reconciliation and authenticated-session journals, two-way procedural compatibility, evidence-preserving recovery, and the canonical-apply boundary.", "tools/qxctl/cmd/qxctl/session_test.go verifies explicit login, refresh, logout, retry, bounded recovery, reauthentication, and interrupted-close resumption over the coordinator primitives.", - "tools/qxctl/cmd/qxctl/main.go, tools/qxctl/internal/ssiagclient/client.go, and tools/qxctl/internal/knowledgeengine/client.go authenticate SSIAG, validate safe authorization evidence, invoke the exact bound coordinator, compose explicit host events, and record the role-sorted engine inventory for reconciliation." + "tools/qxctl/internal/knowledgelifecycle/profile_test.go verifies protected desired-profile compare-and-swap, semantic retry, linked generations, fixed-layout v1/v2 receipt observation, content drift, unknown-package preservation, and stable inventory identity.", + "tools/qxctl/cmd/qxctl/lifecycle_test.go verifies report-plan safety, dynamic scheduler invariants, disabled apply, and lifecycle Cobra grammar.", + "tools/qxctl/cmd/qxctl/main.go, tools/qxctl/cmd/qxctl/lifecycle.go, tools/qxctl/internal/ssiagclient/client.go, and tools/qxctl/internal/knowledgeengine/client.go authenticate SSIAG, validate safe authorization evidence, invoke the exact bound coordinator, compose explicit host events, collect lifecycle observations, and record the role-sorted engine inventory for reconciliation." ], "feature_id": "ssfv:symphony:knowledge-session-coordinator", - "how": "The C++26 symphony-knowledge-session process statically links the shared foundation, accepts the common bounded process envelope, and keeps reconciliation contexts separate from authenticated authority epochs. Each durable state stream uses a private no-follow lock, fsync-backed dual slots, an atomic head, content digests, stable operation IDs, exact compare-and-swap state, opaque-extension preservation, and evidence-based forward recovery. Its separate report-only lifecycle operation strictly validates caller-supplied desired and observed evidence, negotiates exact receipt/protocol capabilities, and derives stable forward/inverse actions from a dependency ready set. It isolates critical dependency cycles and localized blockers, reports noncritical dependency advisories, enforces declared component capabilities, binds exact receptor targets, and safely sequences package changes without performing discovery, persistence, authorization, or action execution. Go qxctl revalidates its immutable coordinator binding, authenticates the TOPS-scoped SSIAG endpoint, obtains and validates one exact audited decision per session operation, negotiates protocol/version/capabilities, invokes that exact coordinator, and supplies a role-sorted bound-engine inventory only to reconciliation operations. Its explicit transition adapter derives stable step identities from one host event ID and composes status, bounded recovery, close, begin, or checkpoint so interrupted login, refresh, and logout sequences resume from durable journal evidence.", + "how": "The C++26 symphony-knowledge-session process statically links the shared foundation, accepts the common bounded process envelope, and keeps reconciliation contexts separate from authenticated authority epochs. Each durable state stream uses a private no-follow lock, fsync-backed dual slots, an atomic head, content digests, stable operation IDs, exact compare-and-swap state, opaque-extension preservation, and evidence-based forward recovery. Its separate report-only lifecycle operation strictly validates caller-supplied desired and observed evidence, negotiates exact receipt/protocol capabilities, and derives stable forward/inverse actions from a dependency ready set. Stable inventory excludes only collection-time document fields so timestamp refresh cannot restart a transaction. The planner isolates critical dependency cycles and localized blockers, reports noncritical dependency advisories, enforces declared component capabilities, binds exact receptor targets, and safely sequences package changes without performing discovery, persistence, authorization, or action execution. Go qxctl revalidates its immutable coordinator binding, authenticates the TOPS-scoped SSIAG endpoint, obtains and validates exact audited decisions, manages protected per-TOPS desired profiles, scans only fixed-layout validated receipts under selected roots, invokes that exact coordinator, and validates the report-only result. Its explicit transition adapter derives stable step identities from one host event ID and composes status, bounded recovery, close, begin, or checkpoint so interrupted login, refresh, and logout sequences resume from durable journal evidence.", "implementation_languages": [ { "language": "C++26", @@ -67,7 +69,7 @@ }, { "language": "Go", - "role": "Implements qxctl binding revalidation, kernel-authenticated SSIAG authorization requests, decision-boundary validation, exact coordinator invocation, reconciliation/session command grammar, explicit login/refresh/logout transition composition, operation identity, expected-state input, and bound-engine inventory delivery." + "role": "Implements qxctl binding revalidation, kernel-authenticated SSIAG authorization requests, decision-boundary validation, exact coordinator invocation, reconciliation/session/lifecycle command grammar, protected lifecycle profiles, fixed-layout receipt observation, explicit login/refresh/logout transition composition, operation identity, expected-state input, and bound-engine inventory delivery." } ], "implementation_paths": [ @@ -85,8 +87,13 @@ "modules/knowledge-session-coordinator/tests/lifecycle_test.cpp", "modules/knowledge-session-coordinator/tests/process_smoke.sh", "tools/qxctl/cmd/qxctl/commands.go", + "tools/qxctl/cmd/qxctl/lifecycle.go", "tools/qxctl/cmd/qxctl/main.go", "tools/qxctl/internal/knowledgeengine/client.go", + "tools/qxctl/internal/knowledgelifecycle/observation.go", + "tools/qxctl/internal/knowledgelifecycle/profile.go", + "tools/qxctl/internal/knowledgelifecycle/scan_unix.go", + "tools/qxctl/internal/knowledgelifecycle/state_unix.go", "tools/qxctl/internal/ssiagclient/client.go" ], "kind": "feature", @@ -94,7 +101,7 @@ "Does not independently authenticate the operating-system caller or decide permission; SSIAG owns those decisions, and the coordinator validates only the supplied exact safe evidence.", "Does not implement canonical apply; it mutates only protected noncanonical user-scope reconciliation and authenticated-session state.", "Does not call SSIAG or STAV directly, consume credentials or provider secrets, integrate Maestro, or mutate canonical repository state.", - "Does not collect lifecycle observations, administer desired profiles, persist lifecycle plans, execute lifecycle actions, or expose qxctl lifecycle grammar.", + "The coordinator does not collect lifecycle observations or administer desired profiles; qxctl performs those functions but does not persist lifecycle plans or execute lifecycle actions.", "Does not claim system or TOPS provisioning, active docking, or a published module release." ], "owner_contract": "modules/knowledge-session-coordinator/SPEC.md", @@ -111,7 +118,7 @@ "status": "experimental", "title": "Durable knowledge session, reconciliation, and report-only lifecycle coordinator", "what": "Provides an independently installable administrative process that reports exact capabilities, computes deterministic snapshots, maintains one recoverable noncanonical reconciliation context per canonical worktree, maintains separately authorized noncanonical authority epochs per TOPS, subject, and repository, and converts complete desired/observed lifecycle evidence into a deterministic non-mutating dependency plan.", - "when": "Runs only on explicit user-scope qxctl or exact process invocation under a bounded deadline. Reconciliation begins with an explicit inventory. Session operations each require fresh exact SSIAG evidence. An explicit host integration may submit a stable login, refresh, or logout event to qxctl; retry reuses that event ID and resumes from journal evidence. The lifecycle planner runs only when a caller supplies complete digest-bound desired and observation documents, and it returns report-only evidence without acquiring lifecycle authority or state.", + "when": "Runs only on explicit user-scope qxctl or exact process invocation under a bounded deadline. Reconciliation begins with an explicit inventory. Session and qxctl lifecycle operations each require fresh exact SSIAG evidence. An explicit host integration may submit a stable login, refresh, or logout event to qxctl; retry reuses that event ID and resumes from journal evidence. qxctl lifecycle report re-reads its protected profile and re-observes configured roots on every call, then supplies complete digest-bound evidence to the planner, which returns report-only evidence without acquiring lifecycle action authority or state.", "where": "Executes as the inactive, installed-undocked symphony-knowledge-session C++ process in the administrative freezing path and roots checks at its current repository working directory.", "who": "Any host-authorized caller using qxctl, plus maintainers and tests that need caller-neutral, domain-neutral authority-epoch and reconciliation boundaries across independently versioned SKV engines.", "why": "Preserves coherent authority-epoch, worktree, and engine-version evidence when upgrades, retries, logouts, or crashes occur out of sequence, and allows compatible lifecycle work to reorder around localized blockers while separating SSIAG authorization, session state, reconciliation state, lifecycle reports, vector semantics, and apply authority." diff --git a/modules/knowledge-session-coordinator/INSTALL.md b/modules/knowledge-session-coordinator/INSTALL.md index 16208aa..2d31f77 100644 --- a/modules/knowledge-session-coordinator/INSTALL.md +++ b/modules/knowledge-session-coordinator/INSTALL.md @@ -48,4 +48,4 @@ cmake -DINSTALL_PREFIX=/chosen/prefix \ -P build/knowledge-session-coordinator/uninstall.cmake ``` -The procedure removes only files named by this version's receipt model. Canonical knowledge, reconciliation journals, authenticated-session journals, other versions, user files, and containing directories are preserved. qxctl can bind an exact installed version and administer reconciliation and authenticated sessions through that bound executable. The executable also exposes direct report-only lifecycle planning, but qxctl lifecycle administration is not yet integrated; installation, upgrade, rollback, and receipt-owned uninstall remain explicit CMake operations. +The procedure removes only files named by this version's receipt model. Canonical knowledge, reconciliation journals, authenticated-session journals, protected lifecycle profiles, other versions, user files, and containing directories are preserved. qxctl can bind an exact installed version and administer reconciliation, authenticated sessions, and report-only lifecycle planning through that bound executable. qxctl collects lifecycle evidence but does not install, upgrade, roll back, activate, dock, or uninstall packages through the lifecycle surface; receipt-owned install/uninstall remain explicit CMake operations. diff --git a/modules/knowledge-session-coordinator/INTENT.md b/modules/knowledge-session-coordinator/INTENT.md index d10c6f1..f58695c 100644 --- a/modules/knowledge-session-coordinator/INTENT.md +++ b/modules/knowledge-session-coordinator/INTENT.md @@ -19,9 +19,9 @@ Development version `0.1.0-dev` implements user-scope reconciliation and authent ## Deferred Scope -qxctl validates, binds, and invokes this exact inactive-undocked coordinator for reconciliation and authenticated-session operations. qxctl obtains every session-operation decision from the kernel-authenticated SSIAG Unix socket; the coordinator then independently checks the evidence's exact subject, TOPS, operation, resource, audience, scope, policy/configuration digests, binding digest, non-transferability, non-apply status, and expiry. qxctl lifecycle grammar and evidence collection, desired-profile persistence, lifecycle journaling, action execution, vector-engine invocation, observers/hooks, proposal serialization, canonical apply, direct coordinator-to-STAV coordination, system/TOPS binding profiles, format migration beyond the current v1 compatibility window, and live Maestro docking remain unimplemented. +qxctl validates, binds, and invokes this exact inactive-undocked coordinator for reconciliation, authenticated-session, and report-only lifecycle operations. qxctl obtains each authorization decision from the kernel-authenticated SSIAG Unix socket; the coordinator independently validates authorization evidence for stateful session operations, while qxctl validates and binds lifecycle authorization before invoking the authority-free report operation. qxctl lifecycle profile administration and fixed-layout observation are implemented outside this module. Lifecycle journaling, action execution, vector-engine invocation, observers/hooks, proposal serialization, canonical apply, direct coordinator-to-STAV coordination, system/TOPS engine-binding profiles, format migration beyond the current v1 compatibility window, and live Maestro docking remain unimplemented. -qxctl may explicitly compose session primitives into an idempotent login, refresh, or logout transition. That composition is a qxctl responsibility; it adds no coordinator operation, watcher, boot service, or implicit recovery behavior. The report-only lifecycle planner accepts fully supplied, digest-bound desired and observed state; it does not scan configured roots, read protected profiles, persist a plan, or apply an action. +qxctl may explicitly compose session primitives into an idempotent login, refresh, or logout transition. That composition is a qxctl responsibility; it adds no coordinator operation, watcher, boot service, or implicit recovery behavior. The report-only lifecycle planner accepts fully supplied, digest-bound desired and observed state; it does not scan configured roots, read protected profiles, persist a plan, or apply an action. Its observation key uses stable validated inventory rather than document collection time, so a timestamp-only refresh preserves the transaction and semantic action identities. ## Authority diff --git a/modules/knowledge-session-coordinator/MANIFEST.md b/modules/knowledge-session-coordinator/MANIFEST.md index d3c5fcd..66d62e0 100644 --- a/modules/knowledge-session-coordinator/MANIFEST.md +++ b/modules/knowledge-session-coordinator/MANIFEST.md @@ -38,7 +38,7 @@ | `lifecycle_plan` | implemented; deterministic report-only result only | no | | `apply` | disabled | prohibited | -The implemented scope is user-process invocation, user-scope reconciliation, authenticated-session state, and caller-supplied report-only lifecycle planning. System/TOPS binding profiles, configured-root collection, desired-profile administration, lifecycle persistence, and provisioning are not claimed. +The implemented module scope is user-process invocation, user-scope reconciliation, authenticated-session state, and caller-supplied report-only lifecycle planning. qxctl now performs the external desired-profile, configured-root observation, authorization, and invocation responsibilities; those are not coordinator-owned filesystem operations. System/TOPS engine-binding profiles, lifecycle plan/journal persistence, action execution, and provisioning are not claimed. ## Installability @@ -50,4 +50,4 @@ The coordinator statically links `knowledge-vector-engine-cpp` and has no runtim ## Boundaries -There is no network listener, daemon, credential input, secret field, canonical write, hook, watcher, direct SSIAG/STAV call, vector-engine invocation, lifecycle filesystem discovery, action execution, or Maestro dock in this version. qxctl obtains an SSIAG decision and invokes reconciliation or session coordination synchronously through the exact selected receipt. The lifecycle operation is a direct report-only process surface until qxctl integration is separately implemented. Absolute repository/state paths remain only in protected local state and process payloads. +There is no network listener, daemon, credential input, secret field, canonical write, hook, watcher, direct SSIAG/STAV call, vector-engine invocation, lifecycle filesystem discovery, action execution, or Maestro dock in this version. qxctl obtains SSIAG decisions and invokes reconciliation, session coordination, or report-only lifecycle planning synchronously through the exact selected receipt. Absolute repository/state paths remain only in protected local state and process payloads. diff --git a/modules/knowledge-session-coordinator/SKILL.md b/modules/knowledge-session-coordinator/SKILL.md index ea97960..957eaab 100644 --- a/modules/knowledge-session-coordinator/SKILL.md +++ b/modules/knowledge-session-coordinator/SKILL.md @@ -28,6 +28,7 @@ Without arguments, send exactly one bounded `symphony.knowledge.engine-process.v - For every session operation, require a fresh exact SSIAG allow decision and recompute its non-transferable capability binding; never treat the JSON object as bearer authority. - Keep session and reconciliation journals separate. Context references may attach to an authority epoch but never become identity or permission evidence. - Treat lifecycle desired state and observation as caller-supplied, digest-bound evidence. Require full explicit protocol/capability overlap, preserve blocked components, and use the returned dependency-ready set rather than array order or version recency. +- Treat observation collection time as document evidence only. Verify timestamp-only refresh preserves the stable inventory key, transaction, and semantic action identities. - Treat every lifecycle action, receptor identity, blocker, and target-state digest as a report. `apply_authorized: false` is absolute in this slice. ## Stop Conditions diff --git a/modules/knowledge-session-coordinator/SPEC.md b/modules/knowledge-session-coordinator/SPEC.md index 78036dd..ffc3fcd 100644 --- a/modules/knowledge-session-coordinator/SPEC.md +++ b/modules/knowledge-session-coordinator/SPEC.md @@ -101,7 +101,9 @@ The planner emits a deterministic dependency-ready-set graph. Action IDs derive Package selection while active or docked is sequenced as `undock`, `deactivate`, `select`, restore desired activation, and `dock`. Receptor replacement is sequenced as exact old-receptor undock followed by exact desired-receptor dock. Dock actions carry a required `target_receptor_id`; every action carries a digest-bound target state and explicit inverse identity when an inverse exists. Desired upgrade and rollback are equally ordinary forward convergence toward the exact selected receipt; “newest” is never inferred. -The result always declares `apply_authorized: false`. It is disposable noncanonical planning evidence: no lock is acquired, no boot journal or applied state is written, no authorization is requested, no package is installed or removed, no component is activated, and no receptor is contacted. Persistence, qxctl lifecycle invocation, configured-root evidence collection, per-action authorization/compare-and-swap, execution, verification, and audit remain separate gates. +The result always declares `apply_authorized: false`. It is disposable noncanonical planning evidence: no lock is acquired, no boot journal or applied state is written, no authorization is requested by this process, no package is installed or removed, no component is activated, and no receptor is contacted. qxctl now owns configured-root collection, fresh SSIAG authorization, exact invocation, and result validation. Persistence, per-action authorization/compare-and-swap, execution, verification, and audit remain separate gates. + +The observation document digest includes its normalized collection timestamp and therefore identifies exact evidence. The coordinator separately derives stable inventory from the validated observation with `observed_at` and `observation_digest` removed. Transaction, observation-key, and semantic action identities use that stable inventory digest, so repeated collection of unchanged state does not create a false transaction while real inventory, binding, platform, capability, or provider-availability changes still replan. ## Descriptor Truth @@ -113,4 +115,4 @@ Installation uses module-and-version-specific paths and creates no active alias. ## Non-Authorization -This implementation does not authenticate the operating-system caller, decide permission, mutate canonical repository content, run a watcher, install a hook, discover lifecycle state, administer desired profiles, persist or apply a lifecycle plan, invoke a vector engine, directly call SSIAG/STAV, activate an install receipt, or dock with Maestro. SSIAG authenticates and decides; qxctl obtains and transports exact safe evidence for implemented protected operations; the coordinator validates it and mutates only protected noncanonical reconciliation or authenticated-session journals. `lifecycle_plan` performs no mutation. +This implementation does not authenticate the operating-system caller, decide permission, mutate canonical repository content, run a watcher, install a hook, discover lifecycle state, administer desired profiles, persist or apply a lifecycle plan, invoke a vector engine, directly call SSIAG/STAV, activate an install receipt, or dock with Maestro. SSIAG authenticates and decides; qxctl obtains and validates exact lifecycle permission before invoking this authority-free report operation and transports exact safe evidence for implemented protected session operations; the coordinator validates session evidence and mutates only protected noncanonical reconciliation or authenticated-session journals. `lifecycle_plan` performs no mutation. diff --git a/modules/knowledge-session-coordinator/src/lifecycle.cpp b/modules/knowledge-session-coordinator/src/lifecycle.cpp index d7af87f..c128454 100644 --- a/modules/knowledge-session-coordinator/src/lifecycle.cpp +++ b/modules/knowledge-session-coordinator/src/lifecycle.cpp @@ -114,6 +114,7 @@ struct ParsedObservation final { std::string profile_id; std::string tops_id; std::string digest; + std::string stable_inventory_digest; std::optional binding_registry_digest; std::string platform_digest; std::map components; @@ -498,6 +499,7 @@ ParsedObservation parse_observation(const engine::Json& value, std::int64_t dead text_field(value, "profile_id"), text_field(value, "tops_id"), digest_field(value, "observation_digest"), + {}, optional_digest(value.at("binding_registry_digest"), "binding_registry_digest"), {}, {}, {}, false, }; @@ -651,6 +653,11 @@ ParsedObservation parse_observation(const engine::Json& value, std::int64_t dead if (!value.at("observed_at").is_string() || !is_timestamp(value.at("observed_at").get_ref())) { invalid("lifecycle.invalid_field", "observed_at must be a normalized UTC timestamp"); } + auto stable_inventory = value; + stable_inventory.erase("observation_digest"); + stable_inventory.erase("observed_at"); + stable_inventory = normalize_observation(std::move(stable_inventory)); + parsed.stable_inventory_digest = engine::tagged_sha256(stable_inventory.dump()); parsed.digest = verify_document_digest(value, "observation_digest", normalize_observation); return parsed; } @@ -1016,7 +1023,7 @@ engine::Json build_plan( for (const auto& capability : supported_capabilities) capability_basis += capability + "\n"; const auto capability_digest = engine::tagged_sha256(capability_basis); const auto observation_key = engine::tagged_sha256( - desired.digest + "\n" + observation.digest + "\n" + binding + "\n" + + desired.digest + "\n" + observation.stable_inventory_digest + "\n" + binding + "\n" + desired.profile_id + "\n" + desired.tops_id + "\n" + observation.platform_digest + "\n" + capability_digest); const auto transaction_id = "lifecycle-transaction:" + engine::sha256_hex( diff --git a/modules/knowledge-session-coordinator/tests/lifecycle_test.cpp b/modules/knowledge-session-coordinator/tests/lifecycle_test.cpp index 87e8190..b9afcba 100644 --- a/modules/knowledge-session-coordinator/tests/lifecycle_test.cpp +++ b/modules/knowledge-session-coordinator/tests/lifecycle_test.cpp @@ -601,6 +601,31 @@ void test_compatibility_integrity_and_digest_fail_closed() { }, "request.deadline_expired"); } +void test_observation_timestamp_does_not_restart_transaction() { + const auto component_receipt = receipt("stable-inventory"); + const auto desired = desired_state(engine::Json::array({ + desired_component("stable-inventory", component_receipt), + })); + const auto first_observation = observation(engine::Json::array({ + observed_component("stable-inventory", component_receipt), + })); + auto later_observation = first_observation; + later_observation["observed_at"] = "2026-08-04T16:01:00Z"; + finalize_observation(later_observation); + require(first_observation.at("observation_digest") != later_observation.at("observation_digest"), + "fresh observation timestamp did not change document evidence"); + + const auto first = plan(desired, first_observation); + const auto later = plan(desired, later_observation); + require(first.at("transaction_id") == later.at("transaction_id"), + "timestamp-only observation refresh restarted the lifecycle transaction"); + require(first.at("observation_key") == later.at("observation_key"), + "timestamp-only observation refresh changed the stable observation key"); + require(action(first, "stable-inventory", "activate").at("action_id") == + action(later, "stable-inventory", "activate").at("action_id"), + "timestamp-only refresh changed semantic action identity"); +} + void test_bounded_scale_plan() { engine::Json desired_components = engine::Json::array(); engine::Json observed_components = engine::Json::array(); @@ -633,6 +658,7 @@ int main() { test_two_way_selection_and_determinism(); test_receptor_switch_and_safe_package_sequence(); test_compatibility_integrity_and_digest_fail_closed(); + test_observation_timestamp_does_not_restart_transaction(); test_bounded_scale_plan(); std::cout << "knowledge lifecycle planner tests passed\n"; return 0; diff --git a/tools/qxctl/INTENT.md b/tools/qxctl/INTENT.md index ec52ae1..a805713 100644 --- a/tools/qxctl/INTENT.md +++ b/tools/qxctl/INTENT.md @@ -24,7 +24,9 @@ The SKVI, SCLV, SACV, SODV, and SSFV vector-engine grammars are operational as ` `qxctl knowledge session begin|status|checkpoint|close|recover` is also operational. Before every call qxctl reads one protected binding snapshot, resolves and revalidates the exact coordinator installation, authenticates the selected TOPS SSIAG endpoint, and requests the exact operation/resource/audience/scope authorization. It rejects denial, target drift, expiry, caller-class use, transferability, canonical-apply claims, or inconsistent capability evidence before invoking the coordinator. Session operations use stable operation IDs, exact prior state, linked epochs, and explicit unambiguous discovery recovery over protected noncanonical state. They do not record the reconciliation engine inventory. Repository-specific, system, and TOPS binding profiles, safeguard administration, and `knowledge apply` remain unavailable. -`qxctl knowledge session transition` composes those exact operations for an explicit stable `login`, `refresh`, or `logout` host event. Retry is evidence-based and idempotent, refresh rotates authority only when reauthentication is required, and optional recovery is confined to damaged local head/journal evidence. qxctl installs no hook, watcher, login-manager integration, or boot service. The generic desired-state and first-boot lifecycle remains governed by `knowledge/LIFECYCLE.md`; although the coordinator now implements report-only planning over supplied evidence, qxctl does not yet collect, persist, invoke, report, install, remove, activate, bind, or dock through that lifecycle surface. +`qxctl knowledge session transition` composes those exact operations for an explicit stable `login`, `refresh`, or `logout` host event. Retry is evidence-based and idempotent, refresh rotates authority only when reauthentication is required, and optional recovery is confined to damaged local head/journal evidence. qxctl installs no hook, watcher, login-manager integration, or boot service. + +`qxctl knowledge lifecycle profile list|show|set|remove`, `observe`, and `report` implement the current generic lifecycle administration boundary governed by `knowledge/LIFECYCLE.md`. Profiles are protected per TOPS beneath the selected state root, generated and linked by qxctl, and mutated through exact compare-and-swap. Observation scans only fixed receipt layouts under administrator-selected roots, validates content-addressed package evidence, preserves unknown packages, and executes nothing it discovers. Report always re-reads desired state and re-observes roots, obtains a fresh exact SSIAG decision, revalidates the bound coordinator, and presents a validated dynamic dependency plan. It persists no plan and executes no lifecycle action. Installation, removal, activation, engine rebinding, Maestro docking, boot-journal recovery, and lifecycle apply remain unavailable. ## Non-goals - qxctl does not execute hotpath-runtime workloads. diff --git a/tools/qxctl/MANIFEST.md b/tools/qxctl/MANIFEST.md index c0ddc72..0a0f52f 100644 --- a/tools/qxctl/MANIFEST.md +++ b/tools/qxctl/MANIFEST.md @@ -13,6 +13,7 @@ - `README.md` - `cmd/qxctl/main.go` - `cmd/qxctl/commands.go` +- `cmd/qxctl/lifecycle.go` - `cmd/qxctl/ssfv.go` - `internal/knowledgeengine/client.go` - `internal/knowledgeengine/open_relative_unix.go` @@ -20,6 +21,12 @@ - `internal/knowledgebinding/registry.go` - `internal/knowledgebinding/state_unix.go` - `internal/knowledgebinding/state_unsupported.go` +- `internal/knowledgelifecycle/profile.go` +- `internal/knowledgelifecycle/observation.go` +- `internal/knowledgelifecycle/scan_unix.go` +- `internal/knowledgelifecycle/scan_unsupported.go` +- `internal/knowledgelifecycle/state_unix.go` +- `internal/knowledgelifecycle/state_unsupported.go` ## Supported Commands - `qxctl doctor` @@ -57,6 +64,12 @@ - `qxctl knowledge session close --tops-id UUID --operation-id ID --expected-journal-digest DIGEST [--scope user|system] [--state-root PATH] [--repo PATH] [--ttl DURATION] [--json]` - `qxctl knowledge session recover --tops-id UUID --operation-id ID (--expected-journal-digest DIGEST|--discover) [--scope user|system] [--state-root PATH] [--repo PATH] [--ttl DURATION] [--json]` - `qxctl knowledge session transition --tops-id UUID --event login|refresh|logout --event-id ID [--context-ref REF...] [--recover] [--scope user|system] [--state-root PATH] [--repo PATH] [--ttl DURATION] [--json]` +- `qxctl knowledge lifecycle profile list --tops-id UUID [--profile-id ID] [--scope user|system] [--state-root PATH] [--json]` +- `qxctl knowledge lifecycle profile show --tops-id UUID [--profile-id ID] [--scope user|system] [--state-root PATH] [--json]` +- `qxctl knowledge lifecycle profile set --tops-id UUID --input FILE --expected-profile-digest absent|DIGEST [--profile-id ID] [--scope user|system] [--state-root PATH] [--json]` +- `qxctl knowledge lifecycle profile remove --tops-id UUID --expected-profile-digest DIGEST [--profile-id ID] [--scope user|system] [--state-root PATH] [--json]` +- `qxctl knowledge lifecycle observe --tops-id UUID [--profile-id ID | --root PATH...] [--scope user|system] [--state-root PATH] [--json]` +- `qxctl knowledge lifecycle report --tops-id UUID [--profile-id ID] [--prior-applied-state-digest DIGEST] [--scope user|system] [--state-root PATH] [--repo PATH] [--json]` - `qxctl skvi inspect --prefix PATH [--version VERSION] [--repo PATH] [--json]` - `qxctl skvi check --prefix PATH [--version VERSION] [--repo PATH] [--expected-index-digest DIGEST] [--json]` - `qxctl skvi propose --prefix PATH --input FILE [--version VERSION] [--repo PATH] [--json]` @@ -88,7 +101,7 @@ - `qxctl knowledge proposals list|show|verify` - `qxctl knowledge apply ...` is namespace-reserved but unavailable until the common apply gate passes -The qxctl lifecycle administrator is also ratified for future implementation: desired-profile administration, evidence collection, report-only boot convergence, install, upgrade, rollback, receipt inspection, dock, undock, activate, and uninstall. The canonical common lifecycle and receipt-v2 contracts and the coordinator's report-only planner now bound that work; exact qxctl leaf grammar is added only with its reviewed artifact-verification and authorization contracts. No current `module` or `knowledge lifecycle` command should imply these qxctl operations already exist. +Lifecycle action leaves remain reserved for future reviewed implementation: boot-journal recovery, install, upgrade, rollback, dock, undock, activate, deactivate, and uninstall. The implemented profile, observe, and report leaves are noncanonical administration and disposable planning only. They do not imply action execution or apply authority. ## Installability Posture qxctl is installable via standard `go build` or executable directly via `go run` using the Go standard toolchain. It does not require remote runtimes, providers, Docker, Kubernetes, or cloud infrastructure. @@ -117,7 +130,9 @@ The reconciliation command layer revalidates one immutable binding snapshot befo The session command layer reads one immutable binding snapshot to resolve and revalidate the exact coordinator installation, then authenticates SSIAG through the per-TOPS trust configuration and requests one fresh exact decision per operation. It does not attach or record the reconciliation engine inventory. qxctl validates the complete decision/capability boundary before passing it to the coordinator. The coordinator owns journal durability, compatibility, and recovery; SSIAG owns policy decisions; qxctl owns neither. No component may convert safe decision evidence into transferable bearer authority or canonical apply permission. -The session transition layer performs only an explicit idempotent composition of status, bounded discovery recovery, close, begin, and checkpoint. It derives stable step identities from one host event ID, checks current journal checkpoint evidence before retry, obtains a new SSIAG decision for every step, and emits `symphony.knowledge.session-transition-result.v1`. It installs no host integration. Cross-vector desired-state and first-boot contracts are canonical under `knowledge/LIFECYCLE.md`, and the coordinator can now produce a dependency-driven report from supplied evidence. qxctl desired-profile persistence, configured-root observation, planner invocation/report presentation, lifecycle recovery, and apply leaves remain unimplemented. +The session transition layer performs only an explicit idempotent composition of status, bounded discovery recovery, close, begin, and checkpoint. It derives stable step identities from one host event ID, checks current journal checkpoint evidence before retry, obtains a new SSIAG decision for every step, and emits `symphony.knowledge.session-transition-result.v1`. It installs no host integration. + +The lifecycle layer stores protected profiles under `/symphony//qxctl/knowledge/lifecycle/profiles/`. It uses caller-neutral exact SSIAG operations and resources, a persistent no-follow lock, effective-user-owned mode-`0600` files, linked content digests, semantic retry, and durable same-directory replacement. Observation scans only `/share/symphony/receipts///install-receipt.json`; known v1 identities use exact adapters and v2 uses generic content-addressed validation. Unsupported, invalid, unreadable, and ambiguous packages are preserved as unknown evidence. `report` re-observes on every invocation, validates the exact bound coordinator and full plan shape/digest, and never persists or applies the result. Lifecycle boot journaling/recovery and every action/apply leaf remain unimplemented. ## Non-authorizations qxctl is not authorized to write canonical generated artifacts. It may invoke ratified engines to create noncanonical proposals and disposable projections. The Architect-ratified Cobra and Viper libraries and their required cgo-free Go dependencies are authorized only for command grammar and constrained configuration mapping; Python, C bindings, remote configuration backends, in-process vector execution engines, and unrelated third-party dependencies remain prohibited. First-party Symphony libraries remain subordinate to their canonical knowledge vectors. diff --git a/tools/qxctl/README.md b/tools/qxctl/README.md index b924481..538706c 100644 --- a/tools/qxctl/README.md +++ b/tools/qxctl/README.md @@ -150,4 +150,19 @@ go run ./cmd/qxctl knowledge session transition \ The selected SSIAG configuration must map the invoking effective UID/GID and contain an exact grant for each requested `symphony.knowledge.session.*` operation, the opaque `symphony.knowledge.repository:` resource derived from the canonical repository root, audience `qxctl`, and scope `tops:`. With no exact grant—or if STAV is unavailable—the operation fails closed without coordinator mutation. -Generic module and vector additions, removals, upgrades, rollbacks, and first-boot convergence are contractually specified in `knowledge/LIFECYCLE.md` and the common lifecycle schemas. Binding registry v1 remains fixed to its six existing roles. The C++ coordinator can derive forward/inverse component actions from a deterministic dependency ready set when supplied complete desired and observed evidence; it preserves unmanaged components, isolates localized blockers, binds exact receptor targets, and never authorizes apply. qxctl does not yet persist desired state, collect configured-root observations, invoke or present the planner, journal boot convergence, or expose a lifecycle apply command. +Generic module and vector additions, removals, upgrades, rollbacks, and first-boot convergence are contractually specified in `knowledge/LIFECYCLE.md` and the common lifecycle schemas. Binding registry v1 remains fixed to its six existing roles. qxctl now persists protected per-TOPS desired profiles, collects fixed-layout configured-root observations, and invokes the exact bound C++ coordinator for a fresh report-only dependency plan. It preserves unmanaged and unsupported packages, isolates localized blockers, binds exact receptor targets, and never authorizes apply. The stable inventory key excludes observation time, so merely running the report later does not restart an unchanged transaction; real content or compatibility changes cause dynamic replanning. + +```bash +# First profile generation; profile.json uses lifecycle-profile-input.v1. +go run ./cmd/qxctl knowledge lifecycle profile set \ + --tops-id 018f0c3a-7b2d-7e11-8c12-0242ac120002 \ + --input profile.json --expected-profile-digest absent --json + +# Disposable evidence and a fresh non-mutating dependency plan. +go run ./cmd/qxctl knowledge lifecycle observe \ + --tops-id 018f0c3a-7b2d-7e11-8c12-0242ac120002 --profile-id default --json +go run ./cmd/qxctl knowledge lifecycle report \ + --tops-id 018f0c3a-7b2d-7e11-8c12-0242ac120002 --profile-id default --json +``` + +Each operation requires a matching exact `symphony.knowledge.lifecycle.*` SSIAG grant and committed STAV decision. Profile updates use the digest returned by `show` or `list` as the next expected state. Plans, applied state, and boot journals are not yet persisted; installation, uninstall, activation, docking, and lifecycle apply remain unavailable. diff --git a/tools/qxctl/SKILL.md b/tools/qxctl/SKILL.md index aca3cdf..3a9f323 100644 --- a/tools/qxctl/SKILL.md +++ b/tools/qxctl/SKILL.md @@ -24,6 +24,9 @@ Any caller operating within its effective target-host permission should use `qxc - `go run ./cmd/qxctl knowledge reconcile close --operation-id ID --expected-journal-digest sha256:...` - `go run ./cmd/qxctl knowledge reconcile recover --operation-id ID --discover` - `go run ./cmd/qxctl knowledge session transition --tops-id UUID --event login --event-id HOST-EVENT-ID --json` +- `go run ./cmd/qxctl knowledge lifecycle profile set --tops-id UUID --input profile.json --expected-profile-digest absent --json` +- `go run ./cmd/qxctl knowledge lifecycle observe --tops-id UUID --profile-id default --json` +- `go run ./cmd/qxctl knowledge lifecycle report --tops-id UUID --profile-id default --json` - `go run ./cmd/qxctl skvi check --prefix /chosen/prefix` - `go run ./cmd/qxctl skvi project --prefix /chosen/prefix --json` - `go run ./cmd/qxctl skvi propose --prefix /chosen/prefix --input proposal-input.json` @@ -65,7 +68,7 @@ Any caller operating within its effective target-host permission should use `qxc - For implemented SSFV commands, use an exact inactive-undocked installation. Treat snapshots, diffs, and graphs as noncanonical evidence and proposals as unratified. Supply baseline/proposal input only through bounded no-follow JSON files. Never use qxctl to decide feature-worthiness, ratify semantics, apply a proposal, create a `FEATURES.md`, or persist a graph. - Treat the default knowledge session as a login/authentication-to-logout/expiry/revocation authority epoch containing separate worktree reconciliation contexts. Never extend authority across a required re-authentication boundary. - Use `knowledge session transition` only from an explicit reviewed host integration. Reuse one stable event ID for retry, and use `--recover` only when damaged local head/journal evidence should be reconciled. Do not convert denial, incompatibility, or ambiguity into recovery and do not imply that qxctl installs a login or boot integration. -- Keep `symphony.knowledge.engine-binding-registry.v1` fixed to its six roles. Treat canonical desired, observed, planned, applied, and boot-journal component state as separate evidence governed by `knowledge/LIFECYCLE.md`; discovering a new or missing package is never implicit permission to execute, remove, switch, bind, or dock it. The coordinator's report-only planner exists, but qxctl lifecycle collection, persistence, invocation, and apply are not implemented. +- Keep `symphony.knowledge.engine-binding-registry.v1` fixed to its six roles. Treat profile input, desired, observed, planned, applied, and boot-journal component state as separate evidence governed by `knowledge/LIFECYCLE.md`; discovering a new or missing package is never implicit permission to execute, remove, switch, bind, or dock it. Use `knowledge lifecycle profile set` with exact expected state, and use `observe` or `report` only for evidence/planning. Current lifecycle persistence is limited to protected desired profiles; plans, applied state, and boot journals are not persisted, and apply is unavailable. - Keep vector administration, recovery, and audit reconciliation away from hot and warm paths. ## Do-Not-Use-For List diff --git a/tools/qxctl/cmd/qxctl/cli_compat_test.go b/tools/qxctl/cmd/qxctl/cli_compat_test.go index 36c6aef..02b358e 100644 --- a/tools/qxctl/cmd/qxctl/cli_compat_test.go +++ b/tools/qxctl/cmd/qxctl/cli_compat_test.go @@ -31,7 +31,7 @@ func TestCLICompatibility(t *testing.T) { {name: "missing STAV subcommand", args: []string{"stav"}, status: 1, output: "stav failed: STAV subcommand is required: status, verify, query, or doctor\n"}, {name: "unknown STAV subcommand", args: []string{"stav", "unknown"}, status: 1, output: "stav failed: unknown STAV subcommand \"unknown\"\n"}, {name: "prohibited STAV append", args: []string{"stav", "append"}, status: 1, output: "stav failed: qxctl stav append is prohibited; qxctl never submits arbitrary events or edits ledgers\n"}, - {name: "missing knowledge subcommand", args: []string{"knowledge"}, status: 1, output: "knowledge failed: knowledge subcommand is required: engines, reconcile, or session\n"}, + {name: "missing knowledge subcommand", args: []string{"knowledge"}, status: 1, output: "knowledge failed: knowledge subcommand is required: engines, reconcile, session, or lifecycle\n"}, {name: "missing knowledge engines subcommand", args: []string{"knowledge", "engines"}, status: 1, output: "knowledge failed: knowledge engines subcommand is required: list, inspect, doctor, bind, or unbind\n"}, {name: "knowledge bind role required", args: []string{"knowledge", "engines", "bind"}, status: 1, output: help}, {name: "missing knowledge reconcile subcommand", args: []string{"knowledge", "reconcile"}, status: 1, output: "knowledge failed: knowledge reconcile subcommand is required: compatibility, begin, status, checkpoint, close, or recover\n"}, @@ -40,6 +40,11 @@ func TestCLICompatibility(t *testing.T) { {name: "missing knowledge session subcommand", args: []string{"knowledge", "session"}, status: 1, output: "knowledge failed: knowledge session subcommand is required: begin, status, checkpoint, close, recover, or transition\n"}, {name: "session begin TOPS required", args: []string{"knowledge", "session", "begin"}, status: 1, output: "knowledge session begin failed: --tops-id is required\n"}, {name: "session transition event required", args: []string{"knowledge", "session", "transition"}, status: 1, output: "knowledge session transition failed: --event must be login, refresh, or logout\n"}, + {name: "missing knowledge lifecycle subcommand", args: []string{"knowledge", "lifecycle"}, status: 1, output: "knowledge failed: knowledge lifecycle subcommand is required: profile, observe, or report\n"}, + {name: "missing lifecycle profile subcommand", args: []string{"knowledge", "lifecycle", "profile"}, status: 1, output: "knowledge failed: knowledge lifecycle profile subcommand is required: list, show, set, or remove\n"}, + {name: "lifecycle profile list TOPS required", args: []string{"knowledge", "lifecycle", "profile", "list"}, status: 1, output: "knowledge lifecycle profile list failed: --tops-id is required\n"}, + {name: "lifecycle observe TOPS required", args: []string{"knowledge", "lifecycle", "observe"}, status: 1, output: "knowledge lifecycle observe failed: --tops-id is required\n"}, + {name: "lifecycle report TOPS required", args: []string{"knowledge", "lifecycle", "report"}, status: 1, output: "knowledge lifecycle report failed: --tops-id is required\n"}, {name: "missing SKVI subcommand", args: []string{"skvi"}, status: 1, output: "skvi failed: SKVI subcommand is required: inspect, check, propose, or project\n"}, {name: "SKVI prefix required", args: []string{"skvi", "inspect"}, status: 1, output: "skvi inspect failed: --prefix is required\n"}, {name: "missing SCLV subcommand", args: []string{"sclv"}, status: 1, output: "sclv failed: SCLV subcommand is required: inspect, check, propose, recover, or project\n"}, diff --git a/tools/qxctl/cmd/qxctl/commands.go b/tools/qxctl/cmd/qxctl/commands.go index defd6a8..d1c08b0 100644 --- a/tools/qxctl/cmd/qxctl/commands.go +++ b/tools/qxctl/cmd/qxctl/commands.go @@ -112,6 +112,20 @@ type knowledgeSessionOptions struct { jsonOutput bool } +type knowledgeLifecycleOptions struct { + topsID string + scope string + stateRoot string + repository string + profileID string + input string + expectedProfileDigest string + configuredRoots []string + priorAppliedStateDigest string + ttl time.Duration + jsonOutput bool +} + func execute(args []string) int { if len(args) == 0 { printUsage() @@ -202,7 +216,7 @@ func newKnowledgeCommand() *cobra.Command { Use: "knowledge", Args: usageOnlyArgs, RunE: func(*cobra.Command, []string) error { - return fmt.Errorf("knowledge subcommand is required: engines, reconcile, or session") + return fmt.Errorf("knowledge subcommand is required: engines, reconcile, session, or lifecycle") }, } engines := &cobra.Command{ @@ -362,10 +376,93 @@ func newKnowledgeCommand() *cobra.Command { session.AddCommand(transition) session.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) command.AddCommand(session) + + lifecycle := &cobra.Command{ + Use: "lifecycle", + Args: usageOnlyArgs, + RunE: func(*cobra.Command, []string) error { + return fmt.Errorf("knowledge lifecycle subcommand is required: profile, observe, or report") + }, + } + profile := &cobra.Command{ + Use: "profile", + Args: usageOnlyArgs, + RunE: func(*cobra.Command, []string) error { + return fmt.Errorf("knowledge lifecycle profile subcommand is required: list, show, set, or remove") + }, + } + for _, operation := range []string{"list", "show", "set", "remove"} { + options := knowledgeLifecycleOptions{scope: "user", profileID: "default", ttl: 15 * time.Minute} + child := &cobra.Command{ + Use: operation, + Args: usageOnlyArgs, + RunE: func(*cobra.Command, []string) error { + return runKnowledgeLifecycleProfile(operation, options) + }, + } + addKnowledgeLifecycleCommonFlags(child, &options) + if operation == "set" { + child.Flags().StringVar(&options.input, "input", "", "bounded no-follow lifecycle profile input JSON") + } + if operation == "set" || operation == "remove" { + child.Flags().StringVar( + &options.expectedProfileDigest, "expected-profile-digest", "", + "required prior profile state: absent or exact tagged SHA-256 digest", + ) + } + child.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) + profile.AddCommand(child) + } + profile.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) + lifecycle.AddCommand(profile) + + observeOptions := knowledgeLifecycleOptions{scope: "user", profileID: "default", ttl: 15 * time.Minute} + observe := &cobra.Command{ + Use: "observe", + Args: usageOnlyArgs, + RunE: func(*cobra.Command, []string) error { + return runKnowledgeLifecycleObserve(observeOptions) + }, + } + addKnowledgeLifecycleCommonFlags(observe, &observeOptions) + observe.Flags().StringSliceVar( + &observeOptions.configuredRoots, "root", nil, + "explicit trusted installation root for bootstrap observation; repeat as needed", + ) + observe.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) + lifecycle.AddCommand(observe) + + reportOptions := knowledgeLifecycleOptions{scope: "user", profileID: "default", ttl: 15 * time.Minute} + report := &cobra.Command{ + Use: "report", + Args: usageOnlyArgs, + RunE: func(*cobra.Command, []string) error { + return runKnowledgeLifecycleReport(reportOptions) + }, + } + addKnowledgeLifecycleCommonFlags(report, &reportOptions) + report.Flags().StringVar( + &reportOptions.priorAppliedStateDigest, "prior-applied-state-digest", "", + "optional exact tagged SHA-256 digest of the last applied-state evidence", + ) + report.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) + lifecycle.AddCommand(report) + lifecycle.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) + command.AddCommand(lifecycle) command.SetFlagErrorFunc(func(*cobra.Command, error) error { return errUsageOnly }) return command } +func addKnowledgeLifecycleCommonFlags(command *cobra.Command, options *knowledgeLifecycleOptions) { + command.Flags().StringVar(&options.topsID, "tops-id", "", "immutable TOPS UUID") + command.Flags().StringVar(&options.scope, "scope", "user", "SSIAG installation scope: user or system") + command.Flags().StringVar(&options.stateRoot, "state-root", "", "state root; defaults to XDG_STATE_HOME or ~/.local/state") + command.Flags().StringVar(&options.repository, "repo", "", "Symphony repository path; defaults to the current repository") + command.Flags().StringVar(&options.profileID, "profile-id", "default", "exact lifecycle profile identity") + command.Flags().DurationVar(&options.ttl, "ttl", 15*time.Minute, "requested lifecycle authorization lifetime") + command.Flags().BoolVar(&options.jsonOutput, "json", false, "emit JSON") +} + func newSSFVCommand() *cobra.Command { command := &cobra.Command{ Use: "ssfv", @@ -782,6 +879,17 @@ func failurePrefix(args []string) string { return "knowledge session " + args[2] } } + if len(args) > 2 && args[1] == "lifecycle" { + if args[2] == "observe" || args[2] == "report" { + return "knowledge lifecycle " + args[2] + } + if len(args) > 3 && args[2] == "profile" { + switch args[3] { + case "list", "show", "set", "remove": + return "knowledge lifecycle profile " + args[3] + } + } + } case "skvi": if len(args) > 1 { switch args[1] { diff --git a/tools/qxctl/cmd/qxctl/lifecycle.go b/tools/qxctl/cmd/qxctl/lifecycle.go new file mode 100644 index 0000000..78ef4f6 --- /dev/null +++ b/tools/qxctl/cmd/qxctl/lifecycle.go @@ -0,0 +1,768 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgebinding" + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgeengine" + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgelifecycle" + "github.com/QuanuX/Symphony/tools/qxctl/internal/ssiagclient" + qxversion "github.com/QuanuX/Symphony/tools/qxctl/internal/version" +) + +func runKnowledgeLifecycleProfile(operation string, options knowledgeLifecycleOptions) error { + store, err := lifecycleStore(options) + if err != nil { + return err + } + switch operation { + case "list": + result, err := store.List() + if err != nil { + return err + } + if err := authorizeKnowledgeLifecycle(options, "profile.list", lifecycleResource( + options.topsID, "profiles", result.ListDigest)); err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(result) + } + fmt.Printf("Knowledge lifecycle profiles: tops_id=%s count=%d digest=%s canonical=false\n", + result.TOPSID, len(result.Profiles), result.ListDigest) + for _, profile := range result.Profiles { + fmt.Printf("Knowledge lifecycle profile: profile=%s generation=%d mode=%s components=%d digest=%s\n", + profile.ProfileID, profile.Generation, profile.BootMode, profile.ComponentCount, profile.ProfileDigest) + } + return nil + case "show": + snapshot, err := store.Snapshot(options.profileID) + if err != nil { + return err + } + if !snapshot.Exists { + return fmt.Errorf("lifecycle profile %q is absent", options.profileID) + } + if err := authorizeKnowledgeLifecycle(options, "profile.show", lifecycleResource( + options.topsID, options.profileID, snapshot.Profile.ProfileDigest)); err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(snapshot.Profile) + } + fmt.Printf("Knowledge lifecycle profile: profile=%s generation=%d mode=%s components=%d roots=%d digest=%s canonical=false\n", + snapshot.Profile.ProfileID, snapshot.Profile.Generation, snapshot.Profile.BootMode, + len(snapshot.Profile.DesiredState.Components), len(snapshot.Profile.ConfiguredRoots), snapshot.Profile.ProfileDigest) + return nil + case "set": + if options.input == "" || options.expectedProfileDigest == "" { + return fmt.Errorf("--input and --expected-profile-digest are required") + } + data, err := knowledgeengine.ReadPayload(options.input) + if err != nil { + return err + } + input, err := knowledgelifecycle.DecodeProfileInput(data) + if err != nil { + return err + } + if input.ProfileID != options.profileID || input.TOPSID != options.topsID { + return fmt.Errorf("profile input identity does not match --profile-id and --tops-id") + } + inputDigest, err := knowledgelifecycle.ProfileInputDigest(input) + if err != nil { + return err + } + if err := authorizeKnowledgeLifecycle(options, "profile.set", lifecycleResource( + options.topsID, options.profileID, options.expectedProfileDigest+"\n"+inputDigest)); err != nil { + return err + } + profile, changed, err := store.Set(input, options.expectedProfileDigest) + if err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(map[string]any{"changed": changed, "profile": profile}) + } + fmt.Printf("Knowledge lifecycle profile: operation=set profile=%s changed=%t generation=%d mode=%s digest=%s canonical=false\n", + profile.ProfileID, changed, profile.Generation, profile.BootMode, profile.ProfileDigest) + if profile.BootMode == "apply-compatible" { + fmt.Println("Knowledge lifecycle profile: apply-compatible requested; runtime apply remains unavailable and reports only") + } + return nil + case "remove": + if options.expectedProfileDigest == "" { + return fmt.Errorf("--expected-profile-digest is required") + } + if err := authorizeKnowledgeLifecycle(options, "profile.remove", lifecycleResource( + options.topsID, options.profileID, options.expectedProfileDigest)); err != nil { + return err + } + changed, err := store.Remove(options.profileID, options.expectedProfileDigest) + if err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(map[string]any{ + "schema": "qxctl.knowledge.lifecycle-profile-removal.v1", + "profile_id": options.profileID, "tops_id": options.topsID, + "changed": changed, "canonical": false, + }) + } + fmt.Printf("Knowledge lifecycle profile: operation=remove profile=%s changed=%t canonical=false\n", + options.profileID, changed) + return nil + default: + return fmt.Errorf("unsupported knowledge lifecycle profile operation") + } +} + +func runKnowledgeLifecycleObserve(options knowledgeLifecycleOptions) error { + observation, profileDigest, _, err := buildLifecycleObservation(options, len(options.configuredRoots) != 0) + if err != nil { + return err + } + stableDigest, err := knowledgelifecycle.StableInventoryDigest(observation) + if err != nil { + return err + } + if err := authorizeKnowledgeLifecycle(options, "observe", lifecycleResource( + options.topsID, options.profileID, profileDigest+"\n"+stableDigest)); err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(observation) + } + fmt.Printf("Knowledge lifecycle observation: profile=%s components=%d unknown=%d roots=%d stable_digest=%s document_digest=%s canonical=false\n", + observation.ProfileID, len(observation.Components), len(observation.UnknownPackages), + len(observation.ConfiguredRoots), stableDigest, observation.ObservationDigest) + return nil +} + +func runKnowledgeLifecycleReport(options knowledgeLifecycleOptions) error { + if options.priorAppliedStateDigest != "" && !validTaggedDigest(options.priorAppliedStateDigest) { + return fmt.Errorf("--prior-applied-state-digest must be an exact tagged SHA-256 digest") + } + observation, profileDigest, profile, err := buildLifecycleObservation(options, false) + if err != nil { + return err + } + stableDigest, err := knowledgelifecycle.StableInventoryDigest(observation) + if err != nil { + return err + } + if err := authorizeKnowledgeLifecycle(options, "report", lifecycleResource( + options.topsID, options.profileID, profileDigest+"\n"+stableDigest)); err != nil { + return err + } + repositoryRoot, err := resolveKnowledgeRepository(options.repository) + if err != nil { + return err + } + coordinator, err := exactBoundCoordinator(options.stateRoot) + if err != nil { + return err + } + var prior any + if options.priorAppliedStateDigest != "" { + prior = options.priorAppliedStateDigest + } + payload, err := json.Marshal(map[string]any{ + "protocol": "symphony.knowledge.lifecycle-plan-command.v1", + "operation": "lifecycle_plan", + "desired_state": profile.DesiredState, + "observation": observation, + "prior_applied_state_digest": prior, + "client": map[string]any{ + "client_id": "qxctl", "client_version": strings.ReplaceAll(qxversion.Version, " ", "-"), + "process_protocols": []string{"symphony.knowledge.engine-process.v1"}, + "desired_state_read_versions": []uint64{1}, "observation_read_versions": []uint64{1}, + "plan_read_versions": []uint64{1}, "applied_state_read_versions": []uint64{1}, + "receipt_read_versions": []uint64{1, 2}, + "capabilities": []string{ + "dependency-ready-set-v1", "deterministic-action-id-v1", "forward-inverse-v1", + "localized-blocker-isolation-v1", "ordered-safety-phases-v1", + "receipt-v1-adapter", "receipt-v2", "report-only-v1", "unknown-critical-block-v1", + }, + }, + }) + if err != nil { + return fmt.Errorf("encode lifecycle report request: %w", err) + } + response, err := knowledgeengine.InvokeCoordinator( + context.Background(), coordinator.Prefix, coordinator.Version, repositoryRoot, "lifecycle_plan", payload) + if err != nil { + return err + } + plan, err := validateLifecyclePlan( + response.Result, profile.DesiredState.DesiredStateDigest, observation.ObservationDigest, + options.priorAppliedStateDigest) + if err != nil { + return err + } + if options.jsonOutput { + return printIndentedJSON(plan.Raw) + } + fmt.Printf("Knowledge lifecycle report: profile=%s transaction=%s actions=%d ready=%d waiting=%d blocked=%d fatal=%d plan_digest=%s apply_authorized=false canonical=false\n", + options.profileID, plan.TransactionID, plan.ActionCount, plan.ReadyCount, + plan.DeferredCount, plan.BlockedCount, plan.FatalCount, plan.PlanDigest) + if profile.BootMode == "apply-compatible" { + fmt.Println("Knowledge lifecycle report: apply-compatible requested; lifecycle mutation remains unavailable") + } + return nil +} + +func lifecycleStore(options knowledgeLifecycleOptions) (*knowledgelifecycle.Store, error) { + if options.topsID == "" { + return nil, fmt.Errorf("--tops-id is required") + } + if options.scope != "user" && options.scope != "system" { + return nil, fmt.Errorf("--scope must be user or system") + } + if options.ttl <= 0 || options.ttl > 24*time.Hour { + return nil, fmt.Errorf("--ttl must be greater than zero and no more than 24h") + } + return knowledgelifecycle.NewStore(options.stateRoot, options.topsID) +} + +func buildLifecycleObservation( + options knowledgeLifecycleOptions, + explicitRoots bool, +) (knowledgelifecycle.Observation, string, knowledgelifecycle.Profile, error) { + store, err := lifecycleStore(options) + if err != nil { + return knowledgelifecycle.Observation{}, "", knowledgelifecycle.Profile{}, err + } + var profile knowledgelifecycle.Profile + profileDigest := "bootstrap" + roots := append([]string(nil), options.configuredRoots...) + var desired *knowledgelifecycle.DesiredState + if !explicitRoots { + snapshot, err := store.Snapshot(options.profileID) + if err != nil { + return knowledgelifecycle.Observation{}, "", profile, err + } + if !snapshot.Exists { + return knowledgelifecycle.Observation{}, "", profile, fmt.Errorf("lifecycle profile %q is absent", options.profileID) + } + profile = snapshot.Profile + profileDigest = profile.ProfileDigest + roots = append([]string(nil), profile.ConfiguredRoots...) + desired = &profile.DesiredState + } else if len(roots) == 0 { + return knowledgelifecycle.Observation{}, "", profile, fmt.Errorf("at least one --root is required for bootstrap observation") + } + + bindingStore, err := knowledgebinding.NewStore(options.stateRoot) + if err != nil { + return knowledgelifecycle.Observation{}, "", profile, err + } + bindingSnapshot, err := bindingStore.Snapshot() + if err != nil { + return knowledgelifecycle.Observation{}, "", profile, err + } + selected := make(map[string]string) + var bindingDigest *string + var coordinatorIdentity *knowledgelifecycle.Identity + if bindingSnapshot.Exists { + bindingDigest = stringAddress(bindingSnapshot.Registry.RegistryDigest) + for _, binding := range bindingSnapshot.Registry.Bindings { + installed, inspectErr := knowledgeengine.InspectInstallation(binding.Role, binding.Prefix, binding.Version) + if inspectErr != nil || installed.ModuleID != binding.ModuleID || installed.EngineID != binding.EngineID || + installed.ReceiptDigest != binding.ReceiptDigest || installed.ExecutableDigest != binding.ExecutableDigest { + continue + } + selected[binding.Role] = binding.ReceiptDigest + if binding.Role == "coordinator" { + coordinatorIdentity = &knowledgelifecycle.Identity{ + ComponentID: "knowledge-session-coordinator", Version: binding.Version, + ExecutableDigest: binding.ExecutableDigest, + } + } + } + } + qxctlDigest, err := knowledgelifecycle.DigestCurrentExecutable() + if err != nil { + return knowledgelifecycle.Observation{}, "", profile, err + } + observation, err := knowledgelifecycle.Observe(knowledgelifecycle.ObservationInput{ + ProfileID: options.profileID, TOPSID: options.topsID, ConfiguredRoots: roots, + DesiredState: desired, BindingRegistryDigest: bindingDigest, SelectedReceipts: selected, + QxctlIdentity: knowledgelifecycle.Identity{ + ComponentID: "qxctl", Version: strings.ReplaceAll(qxversion.Version, " ", "-"), + ExecutableDigest: qxctlDigest, + }, + CoordinatorIdentity: coordinatorIdentity, + ProviderAvailability: []knowledgelifecycle.ProviderAvailability{ + {ProviderID: "ssiag", Available: true}, + {ProviderID: "knowledge-session-coordinator", Available: coordinatorIdentity != nil}, + {ProviderID: "maestro", Available: false}, + }, + ObservedAt: time.Now().UTC().Truncate(time.Second), + }) + return observation, profileDigest, profile, err +} + +func exactBoundCoordinator(stateRoot string) (knowledgebinding.Binding, error) { + store, err := knowledgebinding.NewStore(stateRoot) + if err != nil { + return knowledgebinding.Binding{}, err + } + snapshot, err := store.Snapshot() + if err != nil { + return knowledgebinding.Binding{}, err + } + if !snapshot.Exists { + return knowledgebinding.Binding{}, fmt.Errorf("knowledge engine binding registry is absent") + } + for _, binding := range snapshot.Registry.Bindings { + if binding.Role != "coordinator" { + continue + } + installed, err := knowledgeengine.InspectInstallation("coordinator", binding.Prefix, binding.Version) + if err != nil { + return knowledgebinding.Binding{}, fmt.Errorf("bound coordinator installation is unavailable: %w", err) + } + if installed.ModuleID != binding.ModuleID || installed.EngineID != binding.EngineID || + installed.ReceiptDigest != binding.ReceiptDigest || installed.ExecutableDigest != binding.ExecutableDigest { + return knowledgebinding.Binding{}, fmt.Errorf("bound coordinator installation no longer matches its content-addressed identity") + } + return binding, nil + } + return knowledgebinding.Binding{}, fmt.Errorf("knowledge-session coordinator is not bound") +} + +func authorizeKnowledgeLifecycle(options knowledgeLifecycleOptions, operation, resource string) error { + if _, err := lifecycleStore(options); err != nil { + return err + } + client, err := ssiagclient.NewForTOPS(options.scope, options.topsID, 4*time.Second) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + if _, err := requireSSIAGStatus(ctx, client, options.topsID, options.scope); err != nil { + return err + } + requestID, err := randomUUID() + if err != nil { + return err + } + correlationID, err := randomUUID() + if err != nil { + return err + } + now := time.Now().UTC().Truncate(time.Second) + request := ssiagclient.AuthorizationRequest{ + Schema: "symphony.ssiag.authorization-request.v1", RequestID: requestID, + CorrelationID: correlationID, Operation: "symphony.knowledge.lifecycle." + operation, + Resource: resource, Audience: "qxctl", Scope: "tops:" + options.topsID, + RequestedAt: now, RequestedExpiresAt: now.Add(options.ttl).UTC().Truncate(time.Second), + } + decision, err := client.Authorize(ctx, request) + if err != nil { + return err + } + if err := validateSessionAuthorization(decision, request, options.topsID); err != nil { + return fmt.Errorf("SSIAG lifecycle authorization rejected: %w", err) + } + return nil +} + +func lifecycleResource(topsID, profileID, evidence string) string { + digest := sha256.Sum256([]byte(topsID + "\n" + profileID + "\n" + evidence)) + return "symphony.knowledge.lifecycle:" + hex.EncodeToString(digest[:]) +} + +type validatedLifecyclePlan struct { + Raw any + TransactionID string + PlanDigest string + ActionCount int + ReadyCount int + DeferredCount int + BlockedCount int + FatalCount int +} + +func validateLifecyclePlan(raw json.RawMessage, desiredDigest, observationDigest, priorDigest string) (validatedLifecyclePlan, error) { + if err := knowledgeengine.ValidateJSONObject(raw, 4*1024*1024); err != nil { + return validatedLifecyclePlan{}, fmt.Errorf("invalid lifecycle plan JSON: %w", err) + } + var object map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&object); err != nil { + return validatedLifecyclePlan{}, err + } + required := []string{ + "protocol", "format_version", "transaction_id", "revision", "previous_plan_digest", + "desired_state_digest", "observation_digest", "observation_key", "prior_applied_state_digest", + "compatibility", "scheduler", "actions", "ready_action_ids", "deferred_action_ids", + "blocked_action_ids", "advisories", "fatal_blockers", "apply_authorized", "canonical", "plan_digest", + } + if len(object) != len(required) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan has an invalid root field set") + } + for _, field := range required { + if _, present := object[field]; !present { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan is missing %s", field) + } + } + transaction, transactionOK := object["transaction_id"].(string) + planDigest, planDigestOK := object["plan_digest"].(string) + revision, revisionOK := object["revision"].(json.Number) + revisionValue := int64(0) + if revisionOK { + revisionValue, _ = revision.Int64() + } + if object["protocol"] != "symphony.knowledge.lifecycle-plan.v1" || object["format_version"] != json.Number("1") || + !transactionOK || !validSessionToken(transaction) || !planDigestOK || !validTaggedDigest(planDigest) || + !revisionOK || revisionValue < 1 || revisionValue > 256 || + object["desired_state_digest"] != desiredDigest || object["observation_digest"] != observationDigest || + !digestOrNil(object["previous_plan_digest"]) || !digestString(object["observation_key"]) || + object["apply_authorized"] != false || object["canonical"] != false { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan identity or non-apply boundary is invalid") + } + if priorDigest == "" { + if object["prior_applied_state_digest"] != nil { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan prior applied-state digest mismatch") + } + } else if object["prior_applied_state_digest"] != priorDigest { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan prior applied-state digest mismatch") + } + compatibility, ok := object["compatibility"].(map[string]any) + if !ok || len(compatibility) != 11 || + !oneOfInterface(compatibility["mode"], "full", "blocked") || + !digestlessVersion(compatibility["coordinator_version"]) || + compatibility["desired_state_version"] != json.Number("1") || + compatibility["observation_version"] != json.Number("1") || + compatibility["plan_version"] != json.Number("1") || + compatibility["applied_state_version"] != json.Number("1") { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan compatibility envelope is invalid") + } + if !validUniqueIntegerArray(compatibility["receipt_versions"], 0, 16, 1, 16) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan receipt compatibility is invalid") + } + if !validUniqueTokenArray(compatibility["required_capabilities"], 1, 128) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan capability compatibility is invalid") + } + if !validUniqueTokenArray(compatibility["missing_capabilities"], 0, 128) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan missing-capability evidence is invalid") + } + if _, ok := compatibility["two_way_procedural_compatibility"].(bool); !ok { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan two-way compatibility evidence is invalid") + } + if reason, ok := compatibility["reason"].(string); !ok || !validPlanText(reason) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan compatibility reason is invalid") + } + scheduler, ok := object["scheduler"].(map[string]any) + if !ok || len(scheduler) != 9 || scheduler["algorithm"] != "dependency_ready_set_v1" || scheduler["dynamic_replanning"] != true || + scheduler["directionality"] != "forward_and_inverse" || scheduler["tie_break"] != "lexicographic_action_id" || + scheduler["cycle_policy"] != "block_cyclic_component_continue_unrelated" || + scheduler["max_actions"] != json.Number("4096") || scheduler["max_replans_per_transaction"] != json.Number("256") || + scheduler["max_attempts_per_action"] != json.Number("8") { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan scheduler contract is invalid") + } + expectedPhases := []string{"lock", "observe", "authorize", "compare_and_swap", "act", "verify", "audit"} + phaseValues, ok := scheduler["safety_phase_order"].([]any) + if !ok || len(phaseValues) != len(expectedPhases) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan safety phase order is invalid") + } + for index, phase := range expectedPhases { + if phaseValues[index] != phase { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan safety phase order is invalid") + } + } + actions, ok := object["actions"].([]any) + if !ok || len(actions) > 4096 { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action collection is invalid") + } + seenActions := make(map[string]string, len(actions)) + actionObjects := make([]map[string]any, 0, len(actions)) + for _, rawAction := range actions { + action, ok := rawAction.(map[string]any) + if !ok || len(action) != 13 { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action field set is invalid") + } + for _, field := range []string{ + "action_id", "component_id", "kind", "direction", "prerequisite_action_ids", + "inverse_action_id", "expected_before_digest", "target_state_digest", "target_receptor_id", + "expected_artifact_digests", "expected_evidence", "disposition", "blockers", + } { + if _, present := action[field]; !present { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action is missing %s", field) + } + } + id, idOK := action["action_id"].(string) + disposition, dispositionOK := action["disposition"].(string) + targetDigest, targetOK := action["target_state_digest"].(string) + componentID, componentOK := action["component_id"].(string) + if !idOK || !validSessionToken(id) || !componentOK || !validSessionToken(componentID) || + !oneOfInterface(action["kind"], "install", "uninstall", "select", "deselect", "activate", "deactivate", "dock", "undock", "verify", "preserve", "report") || + !oneOfInterface(action["direction"], "forward", "inverse", "neutral") || !dispositionOK || + !oneOfText(disposition, "ready", "waiting", "blocked", "completed", "skipped", "fatal") || + !targetOK || !validTaggedDigest(targetDigest) || !digestOrNil(action["expected_before_digest"]) || + !tokenOrNil(action["inverse_action_id"]) || !tokenOrNil(action["target_receptor_id"]) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action identity or disposition is invalid") + } + if !validUniqueTokenArray(action["prerequisite_action_ids"], 0, 4096) || + !validUniqueDigestArray(action["expected_artifact_digests"], 4096) || + !validUniqueTokenArray(action["expected_evidence"], 0, 128) || + !validBlockerArray(action["blockers"], 64) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action collection is invalid") + } + if action["kind"] == "dock" { + if action["target_receptor_id"] == nil { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle dock action lacks its exact receptor") + } + } else if action["target_receptor_id"] != nil { + return validatedLifecyclePlan{}, fmt.Errorf("non-dock lifecycle action carries a receptor") + } + if _, duplicate := seenActions[id]; duplicate { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action identity is duplicated") + } + seenActions[id] = disposition + actionObjects = append(actionObjects, action) + } + for _, action := range actionObjects { + id := action["action_id"].(string) + for _, value := range action["prerequisite_action_ids"].([]any) { + prerequisite := value.(string) + if prerequisite == id || seenActions[prerequisite] == "" { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan prerequisite references an unknown or identical action") + } + } + } + ready, err := validatePlanIDSet(object["ready_action_ids"], seenActions, "ready") + if err != nil { + return validatedLifecyclePlan{}, err + } + deferred, err := validatePlanIDSet(object["deferred_action_ids"], seenActions, "waiting") + if err != nil { + return validatedLifecyclePlan{}, err + } + blocked, err := validatePlanIDSet(object["blocked_action_ids"], seenActions, "blocked", "fatal") + if err != nil { + return validatedLifecyclePlan{}, err + } + fatal, ok := object["fatal_blockers"].([]any) + if !ok || !validBlockerArray(fatal, 4096) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan fatal blocker collection is invalid") + } + if !validAdvisoryArray(object["advisories"], 4096) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan advisory collection is invalid") + } + if ready+deferred+blocked != len(actions) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan action-ID sets do not partition report actions") + } + digestInput := make(map[string]any, len(object)-1) + for key, value := range object { + if key != "plan_digest" { + digestInput[key] = value + } + } + encoded, err := json.Marshal(digestInput) + if err != nil { + return validatedLifecyclePlan{}, err + } + digest := sha256.Sum256(encoded) + if planDigest != "sha256:"+hex.EncodeToString(digest[:]) { + return validatedLifecyclePlan{}, fmt.Errorf("lifecycle plan digest mismatch") + } + var display any + if err := json.Unmarshal(raw, &display); err != nil { + return validatedLifecyclePlan{}, err + } + return validatedLifecyclePlan{ + Raw: display, TransactionID: transaction, PlanDigest: planDigest, ActionCount: len(actions), + ReadyCount: ready, DeferredCount: deferred, BlockedCount: blocked, FatalCount: len(fatal), + }, nil +} + +func validatePlanIDSet(value any, actions map[string]string, dispositions ...string) (int, error) { + values, ok := value.([]any) + if !ok || len(values) > 4096 { + return 0, fmt.Errorf("lifecycle plan action-ID set is invalid") + } + allowed := make(map[string]struct{}, len(dispositions)) + for _, disposition := range dispositions { + allowed[disposition] = struct{}{} + } + seen := make(map[string]struct{}, len(values)) + for _, item := range values { + id, ok := item.(string) + if !ok { + return 0, fmt.Errorf("lifecycle plan action-ID set contains a non-string") + } + disposition, present := actions[id] + if !present { + return 0, fmt.Errorf("lifecycle plan action-ID set references an unknown action") + } + if _, allowedDisposition := allowed[disposition]; !allowedDisposition { + return 0, fmt.Errorf("lifecycle plan action-ID set contradicts its action disposition") + } + if _, duplicate := seen[id]; duplicate { + return 0, fmt.Errorf("lifecycle plan action-ID set contains a duplicate") + } + seen[id] = struct{}{} + } + return len(values), nil +} + +func oneOfText(value string, candidates ...string) bool { + for _, candidate := range candidates { + if value == candidate { + return true + } + } + return false +} + +func oneOfInterface(value any, candidates ...string) bool { + text, ok := value.(string) + return ok && oneOfText(text, candidates...) +} + +func digestString(value any) bool { + text, ok := value.(string) + return ok && validTaggedDigest(text) +} + +func digestOrNil(value any) bool { return value == nil || digestString(value) } + +func tokenOrNil(value any) bool { + if value == nil { + return true + } + text, ok := value.(string) + return ok && validSessionToken(text) +} + +func digestlessVersion(value any) bool { + text, ok := value.(string) + if !ok || text == "" || len(text) > 64 { + return false + } + for _, character := range text { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune(".+-", character) { + continue + } + return false + } + return true +} + +func validUniqueIntegerArray(value any, minimumItems, maximumItems, minimumValue, maximumValue int64) bool { + values, ok := value.([]any) + if !ok || int64(len(values)) < minimumItems || int64(len(values)) > maximumItems { + return false + } + seen := make(map[int64]struct{}, len(values)) + for _, item := range values { + number, ok := item.(json.Number) + if !ok { + return false + } + integer, err := number.Int64() + if err != nil || integer < minimumValue || integer > maximumValue { + return false + } + if _, duplicate := seen[integer]; duplicate { + return false + } + seen[integer] = struct{}{} + } + return true +} + +func validUniqueTokenArray(value any, minimum, maximum int) bool { + return validUniqueStringArray(value, minimum, maximum, validSessionToken) +} + +func validUniqueDigestArray(value any, maximum int) bool { + return validUniqueStringArray(value, 0, maximum, validTaggedDigest) +} + +func validUniqueStringArray(value any, minimum, maximum int, valid func(string) bool) bool { + values, ok := value.([]any) + if !ok || len(values) < minimum || len(values) > maximum { + return false + } + seen := make(map[string]struct{}, len(values)) + for _, item := range values { + text, ok := item.(string) + if !ok || !valid(text) { + return false + } + if _, duplicate := seen[text]; duplicate { + return false + } + seen[text] = struct{}{} + } + return true +} + +func validBlockerArray(value any, maximum int) bool { + values, ok := value.([]any) + if !ok || len(values) > maximum { + return false + } + for _, item := range values { + blocker, ok := item.(map[string]any) + if !ok || len(blocker) != 5 || + !oneOfInterface(blocker["class"], "dependency_wait", "observation_retryable", "compatibility_blocked", "authorization_denied", "integrity_fatal", "critical_state_unknown", "cycle_detected") || + !tokenString(blocker["component_id"]) || !tokenOrNil(blocker["action_id"]) { + return false + } + retryable, retryableOK := blocker["retryable"].(bool) + detail, detailOK := blocker["detail"].(string) + if !retryableOK || !detailOK || !validPlanText(detail) { + return false + } + class := blocker["class"].(string) + if (class == "dependency_wait" || class == "observation_retryable") != retryable && + class != "compatibility_blocked" { + return false + } + } + return true +} + +func validAdvisoryArray(value any, maximum int) bool { + values, ok := value.([]any) + if !ok || len(values) > maximum { + return false + } + for _, item := range values { + advisory, ok := item.(map[string]any) + if !ok || len(advisory) != 5 || advisory["class"] != "noncritical_dependency_unsatisfied" || + !tokenString(advisory["component_id"]) || !tokenString(advisory["target_component_id"]) || + !oneOfInterface(advisory["condition"], "present", "absent", "installed", "active", "inactive", "docked", "undocked", "compatible") { + return false + } + detail, ok := advisory["detail"].(string) + if !ok || !validPlanText(detail) { + return false + } + } + return true +} + +func tokenString(value any) bool { + text, ok := value.(string) + return ok && validSessionToken(text) +} + +func validPlanText(value string) bool { return value != "" && len(value) <= 4096 } + +func stringAddress(value string) *string { return &value } diff --git a/tools/qxctl/cmd/qxctl/lifecycle_test.go b/tools/qxctl/cmd/qxctl/lifecycle_test.go new file mode 100644 index 0000000..4366d20 --- /dev/null +++ b/tools/qxctl/cmd/qxctl/lifecycle_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func TestValidateLifecyclePlanRejectsApplyAndAcceptsDynamicReport(t *testing.T) { + desired := lifecycleTestDigest("desired") + observed := lifecycleTestDigest("observed") + actionID := "lifecycle-action:" + strings.Repeat("a", 64) + plan := map[string]any{ + "protocol": "symphony.knowledge.lifecycle-plan.v1", "format_version": 1, + "transaction_id": "lifecycle-transaction:" + strings.Repeat("b", 64), + "revision": 1, "previous_plan_digest": nil, + "desired_state_digest": desired, "observation_digest": observed, + "observation_key": lifecycleTestDigest("observation-key"), "prior_applied_state_digest": nil, + "compatibility": map[string]any{ + "mode": "full", "coordinator_version": "0.1.0-dev", + "desired_state_version": 1, "observation_version": 1, "plan_version": 1, + "applied_state_version": 1, "receipt_versions": []any{1, 2}, + "required_capabilities": []any{"dependency-ready-set-v1"}, + "missing_capabilities": []any{}, "two_way_procedural_compatibility": true, + "reason": "all report-only lifecycle capabilities are shared", + }, + "scheduler": map[string]any{ + "algorithm": "dependency_ready_set_v1", "dynamic_replanning": true, + "directionality": "forward_and_inverse", "tie_break": "lexicographic_action_id", + "safety_phase_order": []any{"lock", "observe", "authorize", "compare_and_swap", "act", "verify", "audit"}, + "cycle_policy": "block_cyclic_component_continue_unrelated", + "max_actions": 4096, "max_replans_per_transaction": 256, "max_attempts_per_action": 8, + }, + "actions": []any{map[string]any{ + "action_id": actionID, "component_id": "example", "kind": "activate", "direction": "forward", + "prerequisite_action_ids": []any{}, "inverse_action_id": "lifecycle-action:" + strings.Repeat("c", 64), + "expected_before_digest": lifecycleTestDigest("before"), "target_state_digest": lifecycleTestDigest("target"), + "target_receptor_id": nil, "expected_artifact_digests": []any{}, + "expected_evidence": []any{"authorization_required_at_apply"}, "disposition": "ready", "blockers": []any{}, + }}, + "ready_action_ids": []any{actionID}, "deferred_action_ids": []any{}, "blocked_action_ids": []any{}, + "advisories": []any{}, "fatal_blockers": []any{}, "apply_authorized": false, "canonical": false, + } + plan["plan_digest"] = lifecyclePlanDigest(t, plan) + raw, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + validated, err := validateLifecyclePlan(raw, desired, observed, "") + if err != nil || validated.ActionCount != 1 || validated.ReadyCount != 1 || validated.BlockedCount != 0 { + t.Fatalf("valid dynamic report was rejected: %+v err=%v", validated, err) + } + + scheduler := plan["scheduler"].(map[string]any) + scheduler["unknown"] = true + plan["plan_digest"] = lifecyclePlanDigest(t, plan) + raw, _ = json.Marshal(plan) + if _, err := validateLifecyclePlan(raw, desired, observed, ""); err == nil { + t.Fatal("unknown scheduler field was accepted") + } + delete(scheduler, "unknown") + + action := plan["actions"].([]any)[0].(map[string]any) + action["expected_evidence"] = []any{"authorization_required_at_apply", "authorization_required_at_apply"} + plan["plan_digest"] = lifecyclePlanDigest(t, plan) + raw, _ = json.Marshal(plan) + if _, err := validateLifecyclePlan(raw, desired, observed, ""); err == nil { + t.Fatal("duplicate action evidence was accepted") + } + action["expected_evidence"] = []any{"authorization_required_at_apply"} + action["prerequisite_action_ids"] = []any{"lifecycle-action:" + strings.Repeat("d", 64)} + plan["plan_digest"] = lifecyclePlanDigest(t, plan) + raw, _ = json.Marshal(plan) + if _, err := validateLifecyclePlan(raw, desired, observed, ""); err == nil { + t.Fatal("unknown prerequisite action was accepted") + } + action["prerequisite_action_ids"] = []any{} + + plan["apply_authorized"] = true + plan["plan_digest"] = lifecyclePlanDigest(t, plan) + raw, _ = json.Marshal(plan) + if _, err := validateLifecyclePlan(raw, desired, observed, ""); err == nil { + t.Fatal("engine-declared lifecycle apply authority was accepted") + } +} + +func TestLifecycleCommandGrammarIsRegistered(t *testing.T) { + root, err := newRootCommand() + if err != nil { + t.Fatal(err) + } + command, _, err := root.Find([]string{"knowledge", "lifecycle", "profile", "set"}) + if err != nil || command == nil || command.Name() != "set" { + t.Fatalf("lifecycle profile set grammar is absent: command=%v err=%v", command, err) + } + command, _, err = root.Find([]string{"knowledge", "lifecycle", "report"}) + if err != nil || command == nil || command.Name() != "report" { + t.Fatalf("lifecycle report grammar is absent: command=%v err=%v", command, err) + } +} + +func lifecyclePlanDigest(t *testing.T, plan map[string]any) string { + t.Helper() + copy := make(map[string]any, len(plan)) + for key, value := range plan { + if key != "plan_digest" { + copy[key] = value + } + } + encoded, err := json.Marshal(copy) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func lifecycleTestDigest(value string) string { + digest := sha256.Sum256([]byte(value)) + return "sha256:" + hex.EncodeToString(digest[:]) +} diff --git a/tools/qxctl/cmd/qxctl/main.go b/tools/qxctl/cmd/qxctl/main.go index eac28f7..05cf9ce 100644 --- a/tools/qxctl/cmd/qxctl/main.go +++ b/tools/qxctl/cmd/qxctl/main.go @@ -76,6 +76,12 @@ func printUsage() { fmt.Println(" knowledge session close --tops-id UUID --operation-id ID --expected-journal-digest DIGEST [--json] Close an authenticated session") fmt.Println(" knowledge session recover --tops-id UUID --operation-id ID (--expected-journal-digest DIGEST|--discover) [--json] Recover authenticated session evidence") fmt.Println(" knowledge session transition --tops-id UUID --event login|refresh|logout --event-id ID [--recover] [--json] Converge an explicit host lifecycle event") + fmt.Println(" knowledge lifecycle profile list --tops-id UUID [--profile-id ID] [--json] List protected lifecycle profiles") + fmt.Println(" knowledge lifecycle profile show --tops-id UUID [--profile-id ID] [--json] Read one protected lifecycle profile") + fmt.Println(" knowledge lifecycle profile set --tops-id UUID --input FILE --expected-profile-digest STATE [--profile-id ID] [--json] Commit exact desired profile intent") + fmt.Println(" knowledge lifecycle profile remove --tops-id UUID --expected-profile-digest DIGEST [--profile-id ID] [--json] Remove one protected lifecycle profile") + fmt.Println(" knowledge lifecycle observe --tops-id UUID [--profile-id ID | --root PATH...] [--json] Inventory fixed-layout receipts without execution") + fmt.Println(" knowledge lifecycle report --tops-id UUID [--profile-id ID] [--prior-applied-state-digest DIGEST] [--json] Re-observe and produce a dynamic report-only plan") fmt.Println(" skvi inspect --prefix PATH [--version VERSION] [--json] Inspect an exact installed SKVI engine") fmt.Println(" skvi check --prefix PATH [--version VERSION] [--json] Check canonical SKVI index truth") fmt.Println(" skvi propose --prefix PATH --input FILE [--version VERSION] [--json] Prepare a caller-declared proposal") diff --git a/tools/qxctl/cmd/qxctl/testdata/help.golden b/tools/qxctl/cmd/qxctl/testdata/help.golden index c7a4909..90d7e48 100644 --- a/tools/qxctl/cmd/qxctl/testdata/help.golden +++ b/tools/qxctl/cmd/qxctl/testdata/help.golden @@ -41,6 +41,12 @@ Commands: knowledge session close --tops-id UUID --operation-id ID --expected-journal-digest DIGEST [--json] Close an authenticated session knowledge session recover --tops-id UUID --operation-id ID (--expected-journal-digest DIGEST|--discover) [--json] Recover authenticated session evidence knowledge session transition --tops-id UUID --event login|refresh|logout --event-id ID [--recover] [--json] Converge an explicit host lifecycle event + knowledge lifecycle profile list --tops-id UUID [--profile-id ID] [--json] List protected lifecycle profiles + knowledge lifecycle profile show --tops-id UUID [--profile-id ID] [--json] Read one protected lifecycle profile + knowledge lifecycle profile set --tops-id UUID --input FILE --expected-profile-digest STATE [--profile-id ID] [--json] Commit exact desired profile intent + knowledge lifecycle profile remove --tops-id UUID --expected-profile-digest DIGEST [--profile-id ID] [--json] Remove one protected lifecycle profile + knowledge lifecycle observe --tops-id UUID [--profile-id ID | --root PATH...] [--json] Inventory fixed-layout receipts without execution + knowledge lifecycle report --tops-id UUID [--profile-id ID] [--prior-applied-state-digest DIGEST] [--json] Re-observe and produce a dynamic report-only plan skvi inspect --prefix PATH [--version VERSION] [--json] Inspect an exact installed SKVI engine skvi check --prefix PATH [--version VERSION] [--json] Check canonical SKVI index truth skvi propose --prefix PATH --input FILE [--version VERSION] [--json] Prepare a caller-declared proposal diff --git a/tools/qxctl/internal/knowledgelifecycle/observation.go b/tools/qxctl/internal/knowledgelifecycle/observation.go new file mode 100644 index 0000000..5d4897b --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/observation.go @@ -0,0 +1,674 @@ +package knowledgelifecycle + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgeengine" +) + +const maxReceiptBytes = 256 * 1024 + +type Identity struct { + ComponentID string `json:"component_id"` + Version string `json:"version"` + ExecutableDigest string `json:"executable_digest"` +} + +type ProviderAvailability struct { + ProviderID string `json:"provider_id"` + Available bool `json:"available"` +} + +type Platform struct { + OS string `json:"os"` + KernelABI string `json:"kernel_abi"` + Architecture string `json:"architecture"` + QxctlIdentity Identity `json:"qxctl_identity"` + CoordinatorIdentity *Identity `json:"coordinator_identity"` + ProviderAvailability []ProviderAvailability `json:"provider_availability"` + CompatibilityDigest string `json:"compatibility_digest"` +} + +type ObservedPackage struct { + PackageID string `json:"package_id"` + Version string `json:"version"` + InstallRoot string `json:"install_root"` + ReceiptProtocol string `json:"receipt_protocol"` + ReceiptDigest string `json:"receipt_digest"` + Integrity string `json:"integrity"` + EntryPointsValidated bool `json:"entry_points_validated"` +} + +type ObservedComponent struct { + ComponentID string `json:"component_id"` + ComponentKind string `json:"component_kind"` + ModuleID string `json:"module_id"` + VectorID *string `json:"vector_id"` + EngineID *string `json:"engine_id"` + Packages []ObservedPackage `json:"packages"` + SelectedPackageDigest *string `json:"selected_package_digest"` + Activation string `json:"activation"` + Docking string `json:"docking"` + ReceptorID *string `json:"receptor_id"` + Capabilities []string `json:"capabilities"` + PlatformCompatibility string `json:"platform_compatibility"` + ObservationDigest string `json:"observation_digest"` +} + +type UnknownPackage struct { + InstallRoot string `json:"install_root"` + ReceiptPath string `json:"receipt_path"` + Reason string `json:"reason"` + Preserved bool `json:"preserved"` +} + +type Observation struct { + Protocol string `json:"protocol"` + FormatVersion uint64 `json:"format_version"` + ProfileID string `json:"profile_id"` + TOPSID string `json:"tops_id"` + ConfiguredRoots []string `json:"configured_roots"` + Platform Platform `json:"platform"` + BindingRegistryDigest *string `json:"binding_registry_digest"` + Components []ObservedComponent `json:"components"` + UnknownPackages []UnknownPackage `json:"unknown_packages"` + ObservedAt string `json:"observed_at"` + Canonical bool `json:"canonical"` + ObservationDigest string `json:"observation_digest"` +} + +type ObservationInput struct { + ProfileID string + TOPSID string + ConfiguredRoots []string + DesiredState *DesiredState + BindingRegistryDigest *string + SelectedReceipts map[string]string + QxctlIdentity Identity + CoordinatorIdentity *Identity + ProviderAvailability []ProviderAvailability + ObservedAt time.Time +} + +type receiptCandidate struct { + root string + relativePath string + module string + version string + data []byte + readable bool +} + +type componentIdentity struct { + componentID string + kind string + moduleID string + vectorID *string + engineID *string + role string +} + +type packageEvidence struct { + identity componentIdentity + packageState ObservedPackage + capabilities []string + compatible bool + receiptPath string +} + +type receiptV1 struct { + Protocol string `json:"protocol"` + ModuleID string `json:"module_id"` + Version string `json:"version"` + InstallScope string `json:"install_scope"` + PrefixMode string `json:"prefix_mode"` + State string `json:"state"` + Active bool `json:"active"` + DefaultReceptor *string `json:"default_receptor"` + Files []string `json:"files"` +} + +type receiptV2File struct { + Path string `json:"path"` + Kind string `json:"kind"` + Size uint64 `json:"size"` + Digest string `json:"digest"` +} + +type receiptV2EntryPoint struct { + EntryPointID string `json:"entry_point_id"` + Kind string `json:"kind"` + Path string `json:"path"` + Protocols []string `json:"protocols"` +} + +type receiptV2Platform struct { + OS string `json:"os"` + Architecture string `json:"architecture"` + KernelABI *string `json:"kernel_abi"` + Critical bool `json:"critical"` +} + +type receiptV2 struct { + Protocol string `json:"protocol"` + FormatVersion uint64 `json:"format_version"` + ComponentID string `json:"component_id"` + ComponentKind string `json:"component_kind"` + ModuleID string `json:"module_id"` + VectorID *string `json:"vector_id"` + EngineID *string `json:"engine_id"` + PackageID string `json:"package_id"` + Version string `json:"version"` + InstallScope string `json:"install_scope"` + PrefixMode string `json:"prefix_mode"` + Files []receiptV2File `json:"files"` + EntryPoints []receiptV2EntryPoint `json:"entry_points"` + ProvidesCapabilities []string `json:"provides_capabilities"` + RequiresCapabilities []string `json:"requires_capabilities"` + CompatibleReceptors []string `json:"compatible_receptors"` + PlatformRequirements []receiptV2Platform `json:"platform_requirements"` + ReceiptDigest string `json:"receipt_digest"` +} + +var v1Identities = map[string]componentIdentity{ + "knowledge-session-coordinator": { + componentID: "knowledge-session-coordinator", kind: "coordinator", + moduleID: "knowledge-session-coordinator", engineID: stringPointer("symphony-knowledge-session"), role: "coordinator", + }, + "skvi-engine": {componentID: "skvi-engine", kind: "vector_engine", moduleID: "skvi-engine", vectorID: stringPointer("skvi"), engineID: stringPointer("symphony-skvi"), role: "skvi"}, + "sclv-engine": {componentID: "sclv-engine", kind: "vector_engine", moduleID: "sclv-engine", vectorID: stringPointer("sclv"), engineID: stringPointer("symphony-sclv"), role: "sclv"}, + "sacv-engine": {componentID: "sacv-engine", kind: "vector_engine", moduleID: "sacv-engine", vectorID: stringPointer("sacv"), engineID: stringPointer("symphony-sacv"), role: "sacv"}, + "sodv-engine": {componentID: "sodv-engine", kind: "vector_engine", moduleID: "sodv-engine", vectorID: stringPointer("sodv"), engineID: stringPointer("symphony-sodv"), role: "sodv"}, + "ssfv-engine": {componentID: "ssfv-engine", kind: "vector_engine", moduleID: "ssfv-engine", vectorID: stringPointer("ssfv"), engineID: stringPointer("symphony-ssfv"), role: "ssfv"}, +} + +func Observe(input ObservationInput) (Observation, error) { + if !safeToken(input.ProfileID, 256) || !validTOPSID(input.TOPSID) || + len(input.ConfiguredRoots) == 0 || len(input.ConfiguredRoots) > 64 || + !validIdentity(input.QxctlIdentity) || + (input.CoordinatorIdentity != nil && !validIdentity(*input.CoordinatorIdentity)) { + return Observation{}, fmt.Errorf("lifecycle observation input identity is invalid") + } + roots := append([]string(nil), input.ConfiguredRoots...) + sort.Strings(roots) + for index, root := range roots { + if !safeAbsolutePath(root) || (index > 0 && root == roots[index-1]) { + return Observation{}, fmt.Errorf("configured roots are invalid or duplicated") + } + } + if input.DesiredState != nil && (input.DesiredState.ProfileID != input.ProfileID || input.DesiredState.TOPSID != input.TOPSID) { + return Observation{}, fmt.Errorf("desired state does not match observation identity") + } + if input.BindingRegistryDigest != nil && !taggedDigest(*input.BindingRegistryDigest) { + return Observation{}, fmt.Errorf("binding registry digest is invalid") + } + if len(input.SelectedReceipts) > 256 { + return Observation{}, fmt.Errorf("selected receipt set exceeds its bound") + } + for role, digest := range input.SelectedReceipts { + if !safeToken(role, 256) || !taggedDigest(digest) { + return Observation{}, fmt.Errorf("selected receipt identity is invalid") + } + } + providers := append([]ProviderAvailability(nil), input.ProviderAvailability...) + sort.Slice(providers, func(i, j int) bool { return canonicalLess(providers[i], providers[j]) }) + seenProviders := make(map[string]struct{}, len(providers)) + for _, provider := range providers { + if !safeToken(provider.ProviderID, 256) { + return Observation{}, fmt.Errorf("provider availability identity is invalid") + } + if _, duplicate := seenProviders[provider.ProviderID]; duplicate { + return Observation{}, fmt.Errorf("provider availability is duplicated") + } + seenProviders[provider.ProviderID] = struct{}{} + } + platform := Platform{ + OS: runtime.GOOS, KernelABI: kernelABI(), Architecture: runtime.GOARCH, + QxctlIdentity: input.QxctlIdentity, CoordinatorIdentity: input.CoordinatorIdentity, + ProviderAvailability: providers, + } + if platform.OS != "linux" && platform.OS != "darwin" { + return Observation{}, fmt.Errorf("native lifecycle observation is unsupported on %s", platform.OS) + } + if platform.OS == "darwin" { + platform.OS = "macos" + } + platformDigestInput := platform + platformDigestInput.CompatibilityDigest = "" + platformObject, err := objectWithout(mustJSON(platformDigestInput), "compatibility_digest") + if err != nil { + return Observation{}, err + } + platform.CompatibilityDigest, err = digestValue(platformObject) + if err != nil { + return Observation{}, err + } + + candidates, err := scanReceiptCandidates(roots) + if err != nil { + return Observation{}, err + } + if len(candidates) > 4096 { + return Observation{}, fmt.Errorf("receipt inventory exceeds 4096 entries") + } + evidence := make([]packageEvidence, 0, len(candidates)) + unknown := make([]UnknownPackage, 0) + for _, candidate := range candidates { + if !candidate.readable { + unknown = append(unknown, unknownFrom(candidate, "unreadable")) + continue + } + protocol, err := receiptProtocol(candidate.data) + if err != nil { + unknown = append(unknown, unknownFrom(candidate, "invalid_receipt")) + continue + } + var item packageEvidence + switch protocol { + case "symphony.knowledge.install-receipt.v1": + item, err = observeV1(candidate) + case "symphony.knowledge.install-receipt.v2": + item, err = observeV2(candidate, platform) + default: + unknown = append(unknown, unknownFrom(candidate, "unsupported_protocol")) + continue + } + if err != nil { + unknown = append(unknown, unknownFrom(candidate, "invalid_receipt")) + continue + } + evidence = append(evidence, item) + } + + grouped := make(map[string][]packageEvidence) + for _, item := range evidence { + grouped[item.identity.componentID] = append(grouped[item.identity.componentID], item) + } + components := make([]ObservedComponent, 0, len(grouped)) + for componentID, items := range grouped { + identity := items[0].identity + conflict := false + seenReceipts := make(map[string]struct{}, len(items)) + for _, item := range items { + if !sameComponentIdentity(identity, item.identity) { + conflict = true + } + if _, duplicate := seenReceipts[item.packageState.ReceiptDigest]; duplicate { + conflict = true + } + seenReceipts[item.packageState.ReceiptDigest] = struct{}{} + } + if conflict { + for _, item := range items { + unknown = append(unknown, UnknownPackage{ + InstallRoot: item.packageState.InstallRoot, ReceiptPath: item.receiptPath, + Reason: "ambiguous_identity", Preserved: true, + }) + } + continue + } + component := ObservedComponent{ + ComponentID: componentID, ComponentKind: identity.kind, ModuleID: identity.moduleID, + VectorID: cloneString(identity.vectorID), EngineID: cloneString(identity.engineID), + Packages: make([]ObservedPackage, 0, len(items)), Activation: "inactive", + Docking: "undocked", Capabilities: []string{}, PlatformCompatibility: "compatible", + } + for _, item := range items { + component.Packages = append(component.Packages, item.packageState) + } + sort.Slice(component.Packages, func(i, j int) bool { return canonicalLess(component.Packages[i], component.Packages[j]) }) + if selected, ok := input.SelectedReceipts[identity.role]; ok { + for _, item := range items { + if item.packageState.ReceiptDigest == selected { + component.SelectedPackageDigest = stringPointer(selected) + component.Capabilities = append([]string(nil), item.capabilities...) + component.PlatformCompatibility = compatibilityText(item.compatible) + break + } + } + } + if component.SelectedPackageDigest == nil && input.DesiredState != nil { + for _, desired := range input.DesiredState.Components { + if desired.ComponentID != componentID || desired.SelectedPackage == nil { + continue + } + for _, item := range items { + if item.packageState.ReceiptDigest == desired.SelectedPackage.ReceiptDigest { + component.PlatformCompatibility = compatibilityText(item.compatible) + break + } + } + } + } + sort.Strings(component.Capabilities) + component.ObservationDigest, err = componentObservationDigest(component) + if err != nil { + return Observation{}, err + } + components = append(components, component) + } + sort.Slice(components, func(i, j int) bool { return canonicalLess(components[i], components[j]) }) + sort.Slice(unknown, func(i, j int) bool { return canonicalLess(unknown[i], unknown[j]) }) + observedAt := input.ObservedAt.UTC().Truncate(time.Second) + if observedAt.IsZero() { + observedAt = Timestamp() + } + observation := Observation{ + Protocol: ObservationProtocol, FormatVersion: 1, ProfileID: input.ProfileID, + TOPSID: input.TOPSID, ConfiguredRoots: roots, Platform: platform, + BindingRegistryDigest: cloneString(input.BindingRegistryDigest), Components: components, + UnknownPackages: unknown, ObservedAt: observedAt.Format(time.RFC3339), Canonical: false, + } + observation.ObservationDigest, err = observationDigest(observation) + if err != nil { + return Observation{}, err + } + return observation, nil +} + +func StableInventoryDigest(observation Observation) (string, error) { + observation.ObservationDigest = "" + observation.ObservedAt = "" + value := mustJSON(observation) + var object map[string]any + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.UseNumber() + if err := decoder.Decode(&object); err != nil { + return "", err + } + delete(object, "observation_digest") + delete(object, "observed_at") + normalizeObservationObject(object) + return digestValue(object) +} + +func DigestCurrentExecutable() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve qxctl executable: %w", err) + } + absolute, err := filepath.Abs(executable) + if err != nil { + return "", err + } + info, err := os.Lstat(absolute) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("qxctl executable must be a no-follow regular file") + } + return hashRegularFile(absolute, 128*1024*1024) +} + +func observeV1(candidate receiptCandidate) (packageEvidence, error) { + identity, ok := v1Identities[candidate.module] + if !ok { + return packageEvidence{}, fmt.Errorf("no v1 adapter exists for module") + } + var receipt receiptV1 + if err := decodeExact(candidate.data, &receipt); err != nil { + return packageEvidence{}, err + } + if receipt.Protocol != "symphony.knowledge.install-receipt.v1" || receipt.ModuleID != candidate.module || + receipt.Version != candidate.version || receipt.InstallScope != "prefix" || + receipt.PrefixMode != "installation_prefix" || receipt.State != "installed_undocked" || + receipt.Active || receipt.DefaultReceptor != nil { + return packageEvidence{}, fmt.Errorf("v1 receipt identity or lifecycle state is invalid") + } + installed, err := knowledgeengine.InspectInstallation(identity.role, candidate.root, candidate.version) + if err != nil { + return packageEvidence{}, err + } + rawDigest := sha256.Sum256(candidate.data) + receiptDigest := "sha256:" + hex.EncodeToString(rawDigest[:]) + if installed.ReceiptDigest != receiptDigest || installed.ModuleID != candidate.module { + return packageEvidence{}, fmt.Errorf("v1 adapter receipt identity mismatch") + } + return packageEvidence{ + identity: identity, + packageState: ObservedPackage{ + PackageID: candidate.module, Version: candidate.version, InstallRoot: candidate.root, + ReceiptProtocol: receipt.Protocol, ReceiptDigest: receiptDigest, + Integrity: "valid", EntryPointsValidated: true, + }, + capabilities: []string{}, compatible: true, receiptPath: candidate.relativePath, + }, nil +} + +func observeV2(candidate receiptCandidate, platform Platform) (packageEvidence, error) { + var receipt receiptV2 + if err := decodeExact(candidate.data, &receipt); err != nil { + return packageEvidence{}, err + } + if err := validateReceiptV2(candidate, receipt); err != nil { + return packageEvidence{}, err + } + integrity := "valid" + fileKinds := make(map[string]string, len(receipt.Files)) + for _, file := range receipt.Files { + fileKinds[file.Path] = file.Kind + digest, size, err := hashTrustedRelative(candidate.root, file.Path, maxObservedFileBytes(file.Kind)) + if err != nil || size != file.Size || digest != file.Digest { + integrity = "invalid" + } + } + entryPointsValid := true + for _, entry := range receipt.EntryPoints { + kind, exists := fileKinds[entry.Path] + if !exists || ((entry.Kind == "executable" || entry.Kind == "adapter") && kind != "executable") { + entryPointsValid = false + } + } + compatible := true + for _, requirement := range receipt.PlatformRequirements { + matches := requirement.OS == platform.OS && requirement.Architecture == platform.Architecture && + (requirement.KernelABI == nil || *requirement.KernelABI == platform.KernelABI) + if requirement.Critical && !matches { + compatible = false + } + } + return packageEvidence{ + identity: componentIdentity{ + componentID: receipt.ComponentID, kind: receipt.ComponentKind, moduleID: receipt.ModuleID, + vectorID: cloneString(receipt.VectorID), engineID: cloneString(receipt.EngineID), + }, + packageState: ObservedPackage{ + PackageID: receipt.PackageID, Version: receipt.Version, InstallRoot: candidate.root, + ReceiptProtocol: receipt.Protocol, ReceiptDigest: receipt.ReceiptDigest, + Integrity: integrity, EntryPointsValidated: entryPointsValid, + }, + capabilities: append([]string(nil), receipt.ProvidesCapabilities...), compatible: compatible, + receiptPath: candidate.relativePath, + }, nil +} + +func validateReceiptV2(candidate receiptCandidate, receipt receiptV2) error { + if receipt.Protocol != "symphony.knowledge.install-receipt.v2" || receipt.FormatVersion != 2 || + receipt.ModuleID != candidate.module || receipt.Version != candidate.version || + !safeToken(receipt.ComponentID, 256) || !safeToken(receipt.ModuleID, 256) || + !safeToken(receipt.PackageID, 256) || !safeVersion(receipt.Version) || + !oneOf(receipt.ComponentKind, "coordinator", "vector_engine", "module", "adapter", "ui", "service") || + !oneOf(receipt.InstallScope, "prefix", "user", "system", "tops") || + receipt.PrefixMode != "installation_prefix" || !taggedDigest(receipt.ReceiptDigest) || + len(receipt.Files) == 0 || len(receipt.Files) > 4096 || len(receipt.EntryPoints) > 128 || + receipt.EntryPoints == nil || receipt.ProvidesCapabilities == nil || + receipt.RequiresCapabilities == nil || receipt.CompatibleReceptors == nil || + receipt.PlatformRequirements == nil { + return fmt.Errorf("v2 receipt identity or collection bound is invalid") + } + for _, optional := range []*string{receipt.VectorID, receipt.EngineID} { + if optional != nil && !safeToken(*optional, 256) { + return fmt.Errorf("v2 receipt optional identity is invalid") + } + } + seenFiles := make(map[string]struct{}, len(receipt.Files)) + for _, file := range receipt.Files { + if !safeRelativePath(file.Path) || !oneOf(file.Kind, "regular", "executable") || !taggedDigest(file.Digest) { + return fmt.Errorf("v2 receipt file entry is invalid") + } + if _, duplicate := seenFiles[file.Path]; duplicate { + return fmt.Errorf("v2 receipt file path is duplicated") + } + seenFiles[file.Path] = struct{}{} + } + seenEntries := make(map[string]struct{}, len(receipt.EntryPoints)) + for _, entry := range receipt.EntryPoints { + if !safeToken(entry.EntryPointID, 256) || !oneOf(entry.Kind, "executable", "descriptor", "adapter") || + !safeRelativePath(entry.Path) || len(entry.Protocols) > 64 || validateTokenSet(entry.Protocols, 64) != nil { + return fmt.Errorf("v2 receipt entry point is invalid") + } + if _, duplicate := seenEntries[entry.EntryPointID]; duplicate { + return fmt.Errorf("v2 receipt entry point identity is duplicated") + } + seenEntries[entry.EntryPointID] = struct{}{} + } + for _, values := range [][]string{receipt.ProvidesCapabilities, receipt.RequiresCapabilities, receipt.CompatibleReceptors} { + if err := validateTokenSet(values, 128); err != nil { + return fmt.Errorf("v2 receipt capability or receptor set is invalid") + } + } + if len(receipt.PlatformRequirements) > 128 { + return fmt.Errorf("v2 platform requirement bound is exceeded") + } + for _, requirement := range receipt.PlatformRequirements { + if !oneOf(requirement.OS, "linux", "macos") || !safeToken(requirement.Architecture, 256) || + (requirement.KernelABI != nil && !safeToken(*requirement.KernelABI, 256)) { + return fmt.Errorf("v2 platform requirement is invalid") + } + } + receiptCopy := receipt + receiptCopy.ReceiptDigest = "" + value, err := objectWithout(mustJSON(receiptCopy), "receipt_digest") + if err != nil { + return err + } + digest, err := digestValue(value) + if err != nil || digest != receipt.ReceiptDigest { + return fmt.Errorf("v2 receipt digest mismatch") + } + return nil +} + +func receiptProtocol(data []byte) (string, error) { + if err := knowledgeengine.ValidateJSONObject(data, maxReceiptBytes); err != nil { + return "", err + } + var header struct { + Protocol string `json:"protocol"` + } + if err := json.Unmarshal(data, &header); err != nil || header.Protocol == "" { + return "", fmt.Errorf("receipt protocol is absent") + } + return header.Protocol, nil +} + +func componentObservationDigest(component ObservedComponent) (string, error) { + component.ObservationDigest = "" + sort.Slice(component.Packages, func(i, j int) bool { return canonicalLess(component.Packages[i], component.Packages[j]) }) + sort.Strings(component.Capabilities) + value, err := objectWithout(mustJSON(component), "observation_digest") + if err != nil { + return "", err + } + return digestValue(value) +} + +func observationDigest(observation Observation) (string, error) { + observation.ObservationDigest = "" + value, err := objectWithout(mustJSON(observation), "observation_digest") + if err != nil { + return "", err + } + normalizeObservationObject(value) + return digestValue(value) +} + +func normalizeObservationObject(value map[string]any) { + sortAnyArray(value["configured_roots"]) + if platform, ok := value["platform"].(map[string]any); ok { + sortAnyArray(platform["provider_availability"]) + } + if components, ok := value["components"].([]any); ok { + for _, item := range components { + if component, ok := item.(map[string]any); ok { + sortAnyArray(component["packages"]) + sortAnyArray(component["capabilities"]) + } + } + sort.Slice(components, func(i, j int) bool { return canonicalLess(components[i], components[j]) }) + value["components"] = components + } + sortAnyArray(value["unknown_packages"]) +} + +func sortAnyArray(value any) { + if array, ok := value.([]any); ok { + sort.Slice(array, func(i, j int) bool { return canonicalLess(array[i], array[j]) }) + } +} + +func unknownFrom(candidate receiptCandidate, reason string) UnknownPackage { + return UnknownPackage{ + InstallRoot: candidate.root, ReceiptPath: candidate.relativePath, + Reason: reason, Preserved: true, + } +} + +func sameComponentIdentity(left, right componentIdentity) bool { + return left.componentID == right.componentID && left.kind == right.kind && left.moduleID == right.moduleID && + equalStringPointer(left.vectorID, right.vectorID) && equalStringPointer(left.engineID, right.engineID) +} + +func equalStringPointer(left, right *string) bool { + return (left == nil && right == nil) || (left != nil && right != nil && *left == *right) +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + return stringPointer(*value) +} + +func compatibilityText(value bool) string { + if value { + return "compatible" + } + return "incompatible" +} + +func validIdentity(identity Identity) bool { + return safeToken(identity.ComponentID, 256) && safeVersion(identity.Version) && taggedDigest(identity.ExecutableDigest) +} + +func safeRelativePath(value string) bool { + if value == "" || len(value) > 4096 || strings.HasPrefix(value, "/") || strings.Contains(value, "\\") || strings.Contains(value, "//") { + return false + } + for _, component := range strings.Split(value, "/") { + if component == "" || component == "." || component == ".." { + return false + } + for _, character := range component { + if character < 0x20 || character == 0x7f { + return false + } + } + } + return true +} + +func maxObservedFileBytes(kind string) int64 { + if kind == "executable" { + return 64 * 1024 * 1024 + } + return 4 * 1024 * 1024 +} diff --git a/tools/qxctl/internal/knowledgelifecycle/profile.go b/tools/qxctl/internal/knowledgelifecycle/profile.go new file mode 100644 index 0000000..00fd638 --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/profile.go @@ -0,0 +1,880 @@ +package knowledgelifecycle + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgebinding" + "github.com/QuanuX/Symphony/tools/qxctl/internal/knowledgeengine" +) + +const ( + ProfileInputProtocol = "symphony.knowledge.lifecycle-profile-input.v1" + ProfileProtocol = "symphony.knowledge.lifecycle-profile.v1" + DesiredProtocol = "symphony.knowledge.lifecycle-desired-state.v1" + ObservationProtocol = "symphony.knowledge.lifecycle-observation.v1" + maxProfileBytes = 1 << 20 + maxProfiles = 256 +) + +type PackageIdentity struct { + PackageID string `json:"package_id"` + Version string `json:"version"` + ReceiptProtocol string `json:"receipt_protocol"` + ReceiptDigest string `json:"receipt_digest"` +} + +type Docking struct { + Disposition string `json:"disposition"` + ReceptorID *string `json:"receptor_id"` +} + +type Dependency struct { + TargetComponentID string `json:"target_component_id"` + Condition string `json:"condition"` + Critical bool `json:"critical"` +} + +type Compatibility struct { + RequiredCapabilities []string `json:"required_capabilities"` + PlatformRequirements []string `json:"platform_requirements"` +} + +type Extension struct { + ExtensionID string `json:"extension_id"` + ExtensionVersion string `json:"extension_version"` + Critical bool `json:"critical"` + Payload any `json:"payload"` + PayloadDigest string `json:"payload_digest"` +} + +type DesiredComponent struct { + ComponentID string `json:"component_id"` + ComponentKind string `json:"component_kind"` + ModuleID string `json:"module_id"` + VectorID *string `json:"vector_id"` + EngineID *string `json:"engine_id"` + Presence string `json:"presence"` + SelectedPackage *PackageIdentity `json:"selected_package"` + Required bool `json:"required"` + InstallScope string `json:"install_scope"` + InstallRoot string `json:"install_root"` + Activation string `json:"activation"` + Docking Docking `json:"docking"` + Dependencies []Dependency `json:"dependencies"` + Compatibility Compatibility `json:"compatibility"` + Extensions []Extension `json:"extensions"` +} + +type ProfileInput struct { + Protocol string `json:"protocol"` + FormatVersion uint64 `json:"format_version"` + ProfileID string `json:"profile_id"` + TOPSID string `json:"tops_id"` + ConfiguredRoots []string `json:"configured_roots"` + BootMode string `json:"boot_mode"` + Components []DesiredComponent `json:"components"` + Extensions []Extension `json:"extensions"` +} + +type DesiredState struct { + Protocol string `json:"protocol"` + FormatVersion uint64 `json:"format_version"` + ProfileID string `json:"profile_id"` + TOPSID string `json:"tops_id"` + Generation uint64 `json:"generation"` + PreviousDesiredStateDigest *string `json:"previous_desired_state_digest"` + Components []DesiredComponent `json:"components"` + Extensions []Extension `json:"extensions"` + Canonical bool `json:"canonical"` + DesiredStateDigest string `json:"desired_state_digest"` +} + +type Profile struct { + Protocol string `json:"protocol"` + FormatVersion uint64 `json:"format_version"` + ProfileID string `json:"profile_id"` + TOPSID string `json:"tops_id"` + Generation uint64 `json:"generation"` + PreviousProfileDigest *string `json:"previous_profile_digest"` + ConfiguredRoots []string `json:"configured_roots"` + BootMode string `json:"boot_mode"` + DesiredState DesiredState `json:"desired_state"` + Canonical bool `json:"canonical"` + ProfileDigest string `json:"profile_digest"` +} + +type Snapshot struct { + Exists bool `json:"exists"` + Profile Profile `json:"profile"` +} + +type ProfileSummary struct { + ProfileID string `json:"profile_id"` + TOPSID string `json:"tops_id"` + Generation uint64 `json:"generation"` + BootMode string `json:"boot_mode"` + ComponentCount int `json:"component_count"` + ProfileDigest string `json:"profile_digest"` +} + +type ListResult struct { + Schema string `json:"schema"` + TOPSID string `json:"tops_id"` + Profiles []ProfileSummary `json:"profiles"` + Canonical bool `json:"canonical"` + ListDigest string `json:"list_digest"` +} + +type Store struct { + stateRoot string + topsID string +} + +func NewStore(stateRoot, topsID string) (*Store, error) { + if !validTOPSID(topsID) { + return nil, fmt.Errorf("TOPS ID must be a canonical non-nil lowercase RFC UUID") + } + if stateRoot == "" { + var err error + stateRoot, err = knowledgebinding.DefaultStateRoot() + if err != nil { + return nil, err + } + } + canonical, err := canonicalStateRoot(stateRoot) + if err != nil { + return nil, err + } + return &Store{stateRoot: canonical, topsID: topsID}, nil +} + +func (s *Store) StateRoot() string { return s.stateRoot } + +func DecodeProfileInput(data []byte) (ProfileInput, error) { + if err := knowledgeengine.ValidateJSONObject(data, maxProfileBytes); err != nil { + return ProfileInput{}, fmt.Errorf("lifecycle profile input violates bounded JSON rules: %w", err) + } + var input ProfileInput + if err := decodeExact(data, &input); err != nil { + return ProfileInput{}, fmt.Errorf("decode lifecycle profile input: %w", err) + } + if err := validateInput(&input); err != nil { + return ProfileInput{}, err + } + return input, nil +} + +func ProfileInputDigest(input ProfileInput) (string, error) { + if err := validateInput(&input); err != nil { + return "", err + } + return digestValue(input) +} + +func (s *Store) Snapshot(profileID string) (Snapshot, error) { + if !safeToken(profileID, 256) { + return Snapshot{}, fmt.Errorf("profile ID has invalid syntax") + } + var snapshot Snapshot + err := s.withProfileLock(false, func(directory *os.File) error { + data, exists, err := readProfileFile(directory, profileID) + if err != nil || !exists { + snapshot.Exists = exists + return err + } + profile, err := decodeProfile(data) + if err != nil { + return err + } + if profile.ProfileID != profileID || profile.TOPSID != s.topsID { + return fmt.Errorf("lifecycle profile storage identity mismatch") + } + snapshot = Snapshot{Exists: true, Profile: profile} + return nil + }) + return snapshot, err +} + +func (s *Store) List() (ListResult, error) { + result := ListResult{ + Schema: "qxctl.knowledge.lifecycle-profile-list.v1", + TOPSID: s.topsID, Profiles: make([]ProfileSummary, 0), Canonical: false, + } + err := s.withProfileLock(false, func(directory *os.File) error { + files, err := listProfileFiles(directory) + if err != nil { + return err + } + if len(files) > maxProfiles { + return fmt.Errorf("lifecycle profile count exceeds %d", maxProfiles) + } + for _, file := range files { + profile, err := decodeProfile(file.data) + if err != nil { + return err + } + if profile.TOPSID != s.topsID || file.name != profileFileName(profile.ProfileID) { + return fmt.Errorf("lifecycle profile storage identity mismatch") + } + result.Profiles = append(result.Profiles, ProfileSummary{ + ProfileID: profile.ProfileID, TOPSID: profile.TOPSID, + Generation: profile.Generation, BootMode: profile.BootMode, + ComponentCount: len(profile.DesiredState.Components), ProfileDigest: profile.ProfileDigest, + }) + } + return nil + }) + if err != nil { + return ListResult{}, err + } + sort.Slice(result.Profiles, func(i, j int) bool { return result.Profiles[i].ProfileID < result.Profiles[j].ProfileID }) + digest, err := digestValue(map[string]any{ + "schema": result.Schema, "tops_id": result.TOPSID, + "profiles": result.Profiles, "canonical": result.Canonical, + }) + if err != nil { + return ListResult{}, err + } + result.ListDigest = digest + return result, nil +} + +func (s *Store) Set(input ProfileInput, expected string) (Profile, bool, error) { + if input.TOPSID != s.topsID { + return Profile{}, false, fmt.Errorf("profile input TOPS does not match the selected TOPS") + } + if err := validateExpected(expected); err != nil { + return Profile{}, false, err + } + var result Profile + changed := false + err := s.withProfileLock(true, func(directory *os.File) error { + data, exists, err := readProfileFile(directory, input.ProfileID) + if err != nil { + return err + } + var current Profile + if exists { + current, err = decodeProfile(data) + if err != nil { + return err + } + if current.TOPSID != s.topsID || current.ProfileID != input.ProfileID { + return fmt.Errorf("lifecycle profile storage identity mismatch") + } + if sameIntent(current, input) { + result = current + return nil + } + } + if err := requireExpected(current, exists, expected); err != nil { + return err + } + next, err := buildProfile(input, current, exists) + if err != nil { + return err + } + encoded, err := encodeProfile(next) + if err != nil { + return err + } + if err := writeProfileFile(directory, input.ProfileID, encoded); err != nil { + return err + } + result = next + changed = true + return nil + }) + return result, changed, err +} + +func (s *Store) Remove(profileID, expected string) (bool, error) { + if !safeToken(profileID, 256) { + return false, fmt.Errorf("profile ID has invalid syntax") + } + if !taggedDigest(expected) { + return false, fmt.Errorf("--expected-profile-digest must be an exact tagged SHA-256 digest") + } + changed := false + err := s.withProfileLock(true, func(directory *os.File) error { + data, exists, err := readProfileFile(directory, profileID) + if err != nil { + return err + } + if !exists { + return nil + } + current, err := decodeProfile(data) + if err != nil { + return err + } + if current.TOPSID != s.topsID || current.ProfileID != profileID { + return fmt.Errorf("lifecycle profile storage identity mismatch") + } + if err := requireExpected(current, true, expected); err != nil { + return err + } + if err := removeProfileFile(directory, profileID); err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +func buildProfile(input ProfileInput, current Profile, exists bool) (Profile, error) { + generation := uint64(1) + var previousProfile, previousDesired *string + if exists { + if current.Generation >= 9007199254740991 { + return Profile{}, fmt.Errorf("lifecycle profile generation is exhausted") + } + generation = current.Generation + 1 + previousProfile = stringPointer(current.ProfileDigest) + previousDesired = stringPointer(current.DesiredState.DesiredStateDigest) + } + desired := DesiredState{ + Protocol: DesiredProtocol, FormatVersion: 1, ProfileID: input.ProfileID, + TOPSID: input.TOPSID, Generation: generation, + PreviousDesiredStateDigest: previousDesired, + Components: cloneComponents(input.Components), Extensions: cloneExtensions(input.Extensions), + Canonical: false, + } + normalizeDesired(&desired) + digest, err := desiredDigest(desired) + if err != nil { + return Profile{}, err + } + desired.DesiredStateDigest = digest + profile := Profile{ + Protocol: ProfileProtocol, FormatVersion: 1, ProfileID: input.ProfileID, + TOPSID: input.TOPSID, Generation: generation, PreviousProfileDigest: previousProfile, + ConfiguredRoots: append([]string(nil), input.ConfiguredRoots...), BootMode: input.BootMode, + DesiredState: desired, Canonical: false, + } + sort.Strings(profile.ConfiguredRoots) + profile.ProfileDigest, err = profileDigest(profile) + if err != nil { + return Profile{}, err + } + if err := validateProfile(profile); err != nil { + return Profile{}, err + } + return profile, nil +} + +func decodeProfile(data []byte) (Profile, error) { + if err := knowledgeengine.ValidateJSONObject(data, maxProfileBytes); err != nil { + return Profile{}, fmt.Errorf("stored lifecycle profile violates bounded JSON rules: %w", err) + } + var profile Profile + if err := decodeExact(data, &profile); err != nil { + return Profile{}, fmt.Errorf("decode stored lifecycle profile: %w", err) + } + if err := validateProfile(profile); err != nil { + return Profile{}, err + } + return profile, nil +} + +func validateProfile(profile Profile) error { + if profile.Protocol != ProfileProtocol || profile.FormatVersion != 1 || profile.Canonical || + !safeToken(profile.ProfileID, 256) || !validTOPSID(profile.TOPSID) || + profile.Generation == 0 || profile.Generation > 9007199254740991 || + !taggedDigest(profile.ProfileDigest) { + return fmt.Errorf("lifecycle profile identity or generation is invalid") + } + if profile.Generation == 1 && profile.PreviousProfileDigest != nil { + return fmt.Errorf("first lifecycle profile generation has a previous digest") + } + if profile.Generation > 1 && (profile.PreviousProfileDigest == nil || !taggedDigest(*profile.PreviousProfileDigest)) { + return fmt.Errorf("later lifecycle profile generation lacks a previous digest") + } + input := ProfileInput{ + Protocol: ProfileInputProtocol, FormatVersion: 1, ProfileID: profile.ProfileID, + TOPSID: profile.TOPSID, ConfiguredRoots: append([]string(nil), profile.ConfiguredRoots...), + BootMode: profile.BootMode, Components: cloneComponents(profile.DesiredState.Components), + Extensions: cloneExtensions(profile.DesiredState.Extensions), + } + if err := validateInput(&input); err != nil { + return err + } + desired := profile.DesiredState + if desired.Protocol != DesiredProtocol || desired.FormatVersion != 1 || desired.Canonical || + desired.ProfileID != profile.ProfileID || desired.TOPSID != profile.TOPSID || + desired.Generation != profile.Generation || !taggedDigest(desired.DesiredStateDigest) { + return fmt.Errorf("embedded desired-state identity is invalid") + } + if profile.Generation == 1 && desired.PreviousDesiredStateDigest != nil { + return fmt.Errorf("first desired-state generation has a previous digest") + } + if profile.Generation > 1 && (desired.PreviousDesiredStateDigest == nil || !taggedDigest(*desired.PreviousDesiredStateDigest)) { + return fmt.Errorf("later desired-state generation lacks a previous digest") + } + expectedDesired, err := desiredDigest(desired) + if err != nil || expectedDesired != desired.DesiredStateDigest { + return fmt.Errorf("desired-state digest mismatch") + } + expectedProfile, err := profileDigest(profile) + if err != nil || expectedProfile != profile.ProfileDigest { + return fmt.Errorf("lifecycle profile digest mismatch") + } + return nil +} + +func validateInput(input *ProfileInput) error { + if input.Protocol != ProfileInputProtocol || input.FormatVersion != 1 || + !safeToken(input.ProfileID, 256) || !validTOPSID(input.TOPSID) || + (input.BootMode != "report" && input.BootMode != "apply-compatible") || + input.Components == nil || input.Extensions == nil { + return fmt.Errorf("lifecycle profile input identity or boot mode is invalid") + } + if len(input.ConfiguredRoots) == 0 || len(input.ConfiguredRoots) > 64 || len(input.Components) > 4096 || len(input.Extensions) > 64 { + return fmt.Errorf("lifecycle profile input exceeds a collection bound") + } + rootSet := make(map[string]struct{}, len(input.ConfiguredRoots)) + for index, root := range input.ConfiguredRoots { + if !safeAbsolutePath(root) { + return fmt.Errorf("configured root %d is not a safe absolute path", index) + } + if _, duplicate := rootSet[root]; duplicate { + return fmt.Errorf("configured roots contain a duplicate") + } + rootSet[root] = struct{}{} + } + componentIDs := make(map[string]struct{}, len(input.Components)) + for index := range input.Components { + component := &input.Components[index] + if err := validateComponent(component, rootSet); err != nil { + return fmt.Errorf("component %d: %w", index, err) + } + if _, duplicate := componentIDs[component.ComponentID]; duplicate { + return fmt.Errorf("desired component identity is duplicated") + } + componentIDs[component.ComponentID] = struct{}{} + } + if err := validateExtensions(input.Extensions); err != nil { + return fmt.Errorf("profile extensions: %w", err) + } + normalizeInput(input) + return nil +} + +func validateComponent(component *DesiredComponent, roots map[string]struct{}) error { + if !safeToken(component.ComponentID, 256) || !safeToken(component.ModuleID, 256) || + !oneOf(component.ComponentKind, "coordinator", "vector_engine", "module", "adapter", "ui", "service") || + !oneOf(component.Presence, "present", "absent") || + !oneOf(component.InstallScope, "prefix", "user", "system", "tops") || + !oneOf(component.Activation, "inactive", "active", "unmanaged") || + !safeAbsolutePath(component.InstallRoot) { + return fmt.Errorf("identity, lifecycle, scope, or install root is invalid") + } + if _, configured := roots[component.InstallRoot]; !configured { + return fmt.Errorf("install root is outside the configured root set") + } + for _, optional := range []*string{component.VectorID, component.EngineID} { + if optional != nil && !safeToken(*optional, 256) { + return fmt.Errorf("optional identity token is invalid") + } + } + if !oneOf(component.Docking.Disposition, "undocked", "docked", "unmanaged") || + (component.Docking.Disposition == "docked") != (component.Docking.ReceptorID != nil) || + (component.Docking.ReceptorID != nil && !safeToken(*component.Docking.ReceptorID, 256)) { + return fmt.Errorf("docking state is invalid") + } + if component.Presence == "present" { + if component.SelectedPackage == nil { + return fmt.Errorf("present component requires an exact selected package") + } + } else if component.SelectedPackage != nil || component.Activation == "active" || component.Docking.Disposition == "docked" { + return fmt.Errorf("absent component carries active package state") + } + if component.SelectedPackage != nil { + selected := component.SelectedPackage + if !safeToken(selected.PackageID, 256) || !safeVersion(selected.Version) || + !oneOf(selected.ReceiptProtocol, "symphony.knowledge.install-receipt.v1", "symphony.knowledge.install-receipt.v2") || + !taggedDigest(selected.ReceiptDigest) { + return fmt.Errorf("selected package identity is invalid") + } + } + if len(component.Dependencies) > 256 || len(component.Compatibility.RequiredCapabilities) > 128 || + len(component.Compatibility.PlatformRequirements) > 128 || len(component.Extensions) > 64 || + component.Dependencies == nil || component.Compatibility.RequiredCapabilities == nil || + component.Compatibility.PlatformRequirements == nil || component.Extensions == nil { + return fmt.Errorf("component collection bound is exceeded") + } + dependencySet := make(map[string]struct{}, len(component.Dependencies)) + for _, dependency := range component.Dependencies { + if !safeToken(dependency.TargetComponentID, 256) || dependency.TargetComponentID == component.ComponentID || + !oneOf(dependency.Condition, "present", "absent", "installed", "active", "inactive", "docked", "undocked", "compatible") { + return fmt.Errorf("dependency is invalid") + } + key := dependency.TargetComponentID + "\x00" + dependency.Condition + if _, duplicate := dependencySet[key]; duplicate { + return fmt.Errorf("dependency is duplicated") + } + dependencySet[key] = struct{}{} + } + if err := validateTokenSet(component.Compatibility.RequiredCapabilities, 128); err != nil { + return fmt.Errorf("required capabilities: %w", err) + } + if err := validateTokenSet(component.Compatibility.PlatformRequirements, 128); err != nil { + return fmt.Errorf("platform requirements: %w", err) + } + if err := validateExtensions(component.Extensions); err != nil { + return err + } + normalizeComponent(component) + return nil +} + +func validateExtensions(extensions []Extension) error { + seen := make(map[string]struct{}, len(extensions)) + for index := range extensions { + extension := &extensions[index] + if !safeToken(extension.ExtensionID, 256) || !safeVersion(extension.ExtensionVersion) || !taggedDigest(extension.PayloadDigest) { + return fmt.Errorf("extension identity or digest is invalid") + } + key := extension.ExtensionID + "\x00" + extension.ExtensionVersion + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("extension identity is duplicated") + } + seen[key] = struct{}{} + digest, err := digestValue(extension.Payload) + if err != nil || digest != extension.PayloadDigest { + return fmt.Errorf("extension %d payload digest mismatch", index) + } + } + return nil +} + +func validateTokenSet(values []string, maximum int) error { + if len(values) > maximum { + return fmt.Errorf("collection exceeds %d entries", maximum) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if !safeToken(value, 256) { + return fmt.Errorf("token has invalid syntax") + } + if _, duplicate := seen[value]; duplicate { + return fmt.Errorf("token is duplicated") + } + seen[value] = struct{}{} + } + return nil +} + +func normalizeInput(input *ProfileInput) { + sort.Strings(input.ConfiguredRoots) + for index := range input.Components { + normalizeComponent(&input.Components[index]) + } + sort.Slice(input.Components, func(i, j int) bool { return canonicalLess(input.Components[i], input.Components[j]) }) + normalizeExtensions(input.Extensions) +} + +func normalizeDesired(desired *DesiredState) { + for index := range desired.Components { + normalizeComponent(&desired.Components[index]) + } + sort.Slice(desired.Components, func(i, j int) bool { return canonicalLess(desired.Components[i], desired.Components[j]) }) + normalizeExtensions(desired.Extensions) +} + +func normalizeComponent(component *DesiredComponent) { + sort.Slice(component.Dependencies, func(i, j int) bool { return canonicalLess(component.Dependencies[i], component.Dependencies[j]) }) + sort.Strings(component.Compatibility.RequiredCapabilities) + sort.Strings(component.Compatibility.PlatformRequirements) + normalizeExtensions(component.Extensions) +} + +func normalizeExtensions(extensions []Extension) { + sort.Slice(extensions, func(i, j int) bool { return canonicalLess(extensions[i], extensions[j]) }) +} + +func desiredDigest(desired DesiredState) (string, error) { + desired.DesiredStateDigest = "" + normalizeDesired(&desired) + value, err := objectWithout(mustJSON(desired), "desired_state_digest") + if err != nil { + return "", err + } + return digestValue(value) +} + +func profileDigest(profile Profile) (string, error) { + profile.ProfileDigest = "" + sort.Strings(profile.ConfiguredRoots) + normalizeDesired(&profile.DesiredState) + value, err := objectWithout(mustJSON(profile), "profile_digest") + if err != nil { + return "", err + } + return digestValue(value) +} + +func sameIntent(current Profile, input ProfileInput) bool { + currentIntent := ProfileInput{ + Protocol: ProfileInputProtocol, FormatVersion: 1, ProfileID: current.ProfileID, + TOPSID: current.TOPSID, ConfiguredRoots: append([]string(nil), current.ConfiguredRoots...), + BootMode: current.BootMode, Components: cloneComponents(current.DesiredState.Components), + Extensions: cloneExtensions(current.DesiredState.Extensions), + } + normalizeInput(¤tIntent) + normalizeInput(&input) + left, leftErr := marshalCanonical(currentIntent) + right, rightErr := marshalCanonical(input) + return leftErr == nil && rightErr == nil && bytes.Equal(left, right) +} + +func requireExpected(current Profile, exists bool, expected string) error { + if expected == "absent" { + if exists { + return fmt.Errorf("lifecycle profile expected absent but current digest is %s", current.ProfileDigest) + } + return nil + } + if !exists { + return fmt.Errorf("lifecycle profile expected %s but is absent", expected) + } + if current.ProfileDigest != expected { + return fmt.Errorf("lifecycle profile expected %s but current digest is %s", expected, current.ProfileDigest) + } + return nil +} + +func validateExpected(value string) error { + if value != "absent" && !taggedDigest(value) { + return fmt.Errorf("--expected-profile-digest must be absent or an exact tagged SHA-256 digest") + } + return nil +} + +func encodeProfile(profile Profile) ([]byte, error) { + encoded, err := json.MarshalIndent(profile, "", " ") + if err != nil { + return nil, fmt.Errorf("encode lifecycle profile: %w", err) + } + if len(encoded)+1 > maxProfileBytes { + return nil, fmt.Errorf("encoded lifecycle profile exceeds %d bytes", maxProfileBytes) + } + return append(encoded, '\n'), nil +} + +func canonicalStateRoot(root string) (string, error) { + if !filepath.IsAbs(root) || filepath.Clean(root) == string(os.PathSeparator) { + return "", fmt.Errorf("lifecycle state root must be an absolute descendant path") + } + clean := filepath.Clean(root) + if info, err := os.Lstat(clean); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("lifecycle state root must be a no-follow directory") + } + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return "", err + } + return filepath.Clean(resolved), nil + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("inspect lifecycle state root: %w", err) + } + return clean, nil +} + +func profileFileName(profileID string) string { + digest := sha256.Sum256([]byte("lifecycle-profile:" + profileID)) + return "profile-" + hex.EncodeToString(digest[:]) + ".json" +} + +func decodeExact(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("JSON contains trailing data") + } + return err + } + return nil +} + +func marshalCanonical(value any) ([]byte, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + var normalized any + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&normalized); err != nil { + return nil, err + } + return json.Marshal(normalized) +} + +func digestValue(value any) (string, error) { + encoded, err := marshalCanonical(value) + if err != nil { + return "", err + } + digest := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} + +func objectWithout(data []byte, field string) (map[string]any, error) { + var object map[string]any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&object); err != nil { + return nil, err + } + delete(object, field) + return object, nil +} + +func mustJSON(value any) []byte { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func canonicalLess(left, right any) bool { + l, _ := marshalCanonical(left) + r, _ := marshalCanonical(right) + return bytes.Compare(l, r) < 0 +} + +func cloneComponents(value []DesiredComponent) []DesiredComponent { + encoded := mustJSON(value) + var result []DesiredComponent + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + panic(err) + } + return result +} + +func cloneExtensions(value []Extension) []Extension { + encoded := mustJSON(value) + var result []Extension + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&result); err != nil { + panic(err) + } + return result +} + +func taggedDigest(value string) bool { + if len(value) != 71 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, character := range value[7:] { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return false + } + } + return true +} + +func safeToken(value string, maximum int) bool { + if value == "" || len(value) > maximum { + return false + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune("._:-", character) { + continue + } + return false + } + return true +} + +func safeVersion(value string) bool { + if value == "" || len(value) > 64 || value == "." || value == ".." { + return false + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || strings.ContainsRune(".+-", character) { + continue + } + return false + } + return true +} + +func safeAbsolutePath(value string) bool { + if value == "" || len(value) > 4096 || !filepath.IsAbs(value) || filepath.Clean(value) != value || + strings.Contains(value, "\\") || (len(value) > 1 && strings.HasSuffix(value, "/")) { + return false + } + if value == string(os.PathSeparator) { + return true + } + for _, part := range strings.Split(strings.TrimPrefix(value, "/"), "/") { + if part == "" || part == "." || part == ".." { + return false + } + for _, character := range part { + if character < 0x20 || character == 0x7f { + return false + } + } + } + return true +} + +func validTOPSID(value string) bool { + if len(value) != 36 || strings.ToLower(value) != value || value == "00000000-0000-0000-0000-000000000000" { + return false + } + for index, character := range value { + switch index { + case 8, 13, 18, 23: + if character != '-' { + return false + } + default: + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { + return false + } + } + } + return value[14] >= '1' && value[14] <= '8' && strings.Contains("89ab", value[19:20]) +} + +func oneOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func stringPointer(value string) *string { return &value } + +// Timestamp is injectable by tests that build observation evidence. +var Timestamp = func() time.Time { return time.Now().UTC().Truncate(time.Second) } diff --git a/tools/qxctl/internal/knowledgelifecycle/profile_test.go b/tools/qxctl/internal/knowledgelifecycle/profile_test.go new file mode 100644 index 0000000..56c6447 --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/profile_test.go @@ -0,0 +1,307 @@ +package knowledgelifecycle + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +const testTOPSID = "123e4567-e89b-42d3-a456-426614174000" + +func tagged(value string) string { + digest := sha256.Sum256([]byte(value)) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func profileInput(root string) ProfileInput { + return ProfileInput{ + Protocol: ProfileInputProtocol, FormatVersion: 1, ProfileID: "default", TOPSID: testTOPSID, + ConfiguredRoots: []string{root}, BootMode: "report", + Components: []DesiredComponent{{ + ComponentID: "example", ComponentKind: "module", ModuleID: "example", + Presence: "present", Required: true, InstallScope: "prefix", InstallRoot: root, + SelectedPackage: &PackageIdentity{ + PackageID: "example", Version: "1.0.0", + ReceiptProtocol: "symphony.knowledge.install-receipt.v2", ReceiptDigest: tagged("receipt"), + }, + Activation: "inactive", Docking: Docking{Disposition: "undocked"}, + Dependencies: []Dependency{}, Compatibility: Compatibility{ + RequiredCapabilities: []string{}, PlatformRequirements: []string{}, + }, Extensions: []Extension{}, + }}, + Extensions: []Extension{}, + } +} + +func TestProfileStoreCASIdempotencyAndRemoval(t *testing.T) { + stateRoot := t.TempDir() + installRoot := t.TempDir() + store, err := NewStore(stateRoot, testTOPSID) + if err != nil { + t.Fatal(err) + } + input := profileInput(installRoot) + first, changed, err := store.Set(input, "absent") + if err != nil || !changed || first.Generation != 1 || first.PreviousProfileDigest != nil || + first.DesiredState.PreviousDesiredStateDigest != nil { + t.Fatalf("first profile set failed: changed=%t profile=%+v err=%v", changed, first, err) + } + if first.ProfileDigest == first.DesiredState.DesiredStateDigest { + t.Fatal("profile and desired-state digests unexpectedly alias") + } + + retried, changed, err := store.Set(input, "absent") + if err != nil || changed || retried.ProfileDigest != first.ProfileDigest || retried.Generation != 1 { + t.Fatalf("semantic retry was not a stable no-op: changed=%t profile=%+v err=%v", changed, retried, err) + } + + modified := profileInput(installRoot) + modified.BootMode = "apply-compatible" + if _, _, err := store.Set(modified, tagged("stale")); err == nil { + t.Fatal("stale profile compare-and-swap unexpectedly succeeded") + } + second, changed, err := store.Set(modified, first.ProfileDigest) + if err != nil || !changed || second.Generation != 2 || second.PreviousProfileDigest == nil || + *second.PreviousProfileDigest != first.ProfileDigest || second.DesiredState.PreviousDesiredStateDigest == nil || + *second.DesiredState.PreviousDesiredStateDigest != first.DesiredState.DesiredStateDigest { + t.Fatalf("linked profile update failed: changed=%t profile=%+v err=%v", changed, second, err) + } + + snapshot, err := store.Snapshot("default") + if err != nil || !snapshot.Exists || snapshot.Profile.ProfileDigest != second.ProfileDigest { + t.Fatalf("profile snapshot mismatch: %+v err=%v", snapshot, err) + } + listed, err := store.List() + if err != nil || len(listed.Profiles) != 1 || !taggedDigest(listed.ListDigest) { + t.Fatalf("profile list mismatch: %+v err=%v", listed, err) + } + if _, err := store.Remove("default", first.ProfileDigest); err == nil { + t.Fatal("stale profile removal unexpectedly succeeded") + } + removed, err := store.Remove("default", second.ProfileDigest) + if err != nil || !removed { + t.Fatalf("profile removal failed: removed=%t err=%v", removed, err) + } + removed, err = store.Remove("default", second.ProfileDigest) + if err != nil || removed { + t.Fatalf("profile removal retry was not idempotent: removed=%t err=%v", removed, err) + } +} + +func TestProfileInputRejectsUnboundRootAndDigestDrift(t *testing.T) { + input := profileInput(t.TempDir()) + input.Components[0].InstallRoot = t.TempDir() + encoded, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeProfileInput(encoded); err == nil { + t.Fatal("component outside configured roots was accepted") + } + + input = profileInput(t.TempDir()) + input.Extensions = []Extension{{ + ExtensionID: "example", ExtensionVersion: "1", Payload: map[string]any{"enabled": true}, + PayloadDigest: tagged("wrong"), + }} + encoded, _ = json.Marshal(input) + if _, err := DecodeProfileInput(encoded); err == nil { + t.Fatal("extension payload digest drift was accepted") + } + + input = profileInput(t.TempDir()) + input.Components[0].Dependencies = nil + encoded, _ = json.Marshal(input) + if _, err := DecodeProfileInput(encoded); err == nil { + t.Fatal("null required dependency collection was accepted") + } +} + +func TestProfileListRejectsFilenameIdentityDrift(t *testing.T) { + stateRoot := resolvedTempDir(t) + store, err := NewStore(stateRoot, testTOPSID) + if err != nil { + t.Fatal(err) + } + input := profileInput(resolvedTempDir(t)) + if _, _, err := store.Set(input, "absent"); err != nil { + t.Fatal(err) + } + directory := filepath.Join(stateRoot, "symphony", testTOPSID, "qxctl/knowledge/lifecycle/profiles") + if err := os.Rename( + filepath.Join(directory, profileFileName("default")), + filepath.Join(directory, profileFileName("different")), + ); err != nil { + t.Fatal(err) + } + if _, err := store.List(); err == nil { + t.Fatal("profile filename and embedded identity drift was accepted") + } +} + +func TestObservationV2StableInventoryAndIntegrity(t *testing.T) { + root := resolvedTempDir(t) + ownedPath := "lib/example/data.bin" + ownedData := []byte("content-addressed example\n") + writeTestFile(t, filepath.Join(root, ownedPath), ownedData, 0o600) + receipt := receiptV2{ + Protocol: "symphony.knowledge.install-receipt.v2", FormatVersion: 2, + ComponentID: "example", ComponentKind: "module", ModuleID: "example", + PackageID: "example", Version: "1.0.0", InstallScope: "prefix", PrefixMode: "installation_prefix", + Files: []receiptV2File{{Path: ownedPath, Kind: "regular", Size: uint64(len(ownedData)), Digest: taggedBytes(ownedData)}}, + EntryPoints: []receiptV2EntryPoint{{EntryPointID: "descriptor", Kind: "descriptor", Path: ownedPath, Protocols: []string{"example.v1"}}}, + ProvidesCapabilities: []string{"example-capability"}, RequiresCapabilities: []string{}, + CompatibleReceptors: []string{}, PlatformRequirements: []receiptV2Platform{}, + } + receipt.ReceiptDigest = receiptV2Digest(t, receipt) + receiptPath := filepath.Join(root, "share/symphony/receipts/example/1.0.0/install-receipt.json") + encoded, _ := json.Marshal(receipt) + writeTestFile(t, receiptPath, encoded, 0o600) + + first, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 0, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if len(first.Components) != 1 || len(first.UnknownPackages) != 0 || + first.Components[0].Packages[0].Integrity != "valid" || !first.Components[0].Packages[0].EntryPointsValidated { + t.Fatalf("valid v2 receipt was not observed exactly: %+v", first) + } + second, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 1, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if first.ObservationDigest == second.ObservationDigest { + t.Fatal("timestamp refresh did not change document evidence") + } + firstStable, _ := StableInventoryDigest(first) + secondStable, _ := StableInventoryDigest(second) + if firstStable != secondStable { + t.Fatalf("timestamp-only refresh changed stable inventory: %s != %s", firstStable, secondStable) + } + + writeTestFile(t, filepath.Join(root, ownedPath), []byte("drift"), 0o600) + drifted, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 2, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if drifted.Components[0].Packages[0].Integrity != "invalid" { + t.Fatal("receipt-owned file drift was not reported as invalid integrity") + } +} + +func TestObservationV1AdapterAndUnsupportedPreservation(t *testing.T) { + root := resolvedTempDir(t) + version := "0.1.0-dev" + base := "share/doc/symphony/skvi-engine/" + version + "/" + license := "share/licenses/symphony-skvi-engine/" + version + "/" + paths := []string{ + "libexec/symphony/skvi-engine/" + version + "/symphony-skvi", + "share/symphony/receipts/skvi-engine/" + version + "/install-receipt.json", + base + "INTENT.md", base + "MANIFEST.md", base + "INSTALL.md", base + "SKILL.md", base + "SPEC.md", + license + "LICENSE-AGPL-3.0", license + "nlohmann-json-LICENSE.MIT", + } + for _, path := range paths { + if filepath.Base(path) == "install-receipt.json" { + continue + } + mode := os.FileMode(0o600) + if filepath.Base(path) == "symphony-skvi" { + mode = 0o700 + } + writeTestFile(t, filepath.Join(root, path), []byte("fixture\n"), mode) + } + receipt := receiptV1{ + Protocol: "symphony.knowledge.install-receipt.v1", ModuleID: "skvi-engine", Version: version, + InstallScope: "prefix", PrefixMode: "installation_prefix", State: "installed_undocked", + Active: false, Files: paths, + } + encoded, _ := json.Marshal(receipt) + writeTestFile(t, filepath.Join(root, paths[1]), encoded, 0o600) + + observed, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 0, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if len(observed.Components) != 1 || observed.Components[0].ComponentID != "skvi-engine" || + observed.Components[0].Packages[0].ReceiptProtocol != "symphony.knowledge.install-receipt.v1" { + t.Fatalf("v1 adapter did not project the exact known identity: %+v", observed) + } + + unsupportedPath := filepath.Join(root, "share/symphony/receipts/future/9/install-receipt.json") + writeTestFile(t, unsupportedPath, []byte(`{"protocol":"symphony.knowledge.install-receipt.v9"}`), 0o600) + preserved, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 1, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if len(preserved.UnknownPackages) != 1 || preserved.UnknownPackages[0].Reason != "unsupported_protocol" || + !preserved.UnknownPackages[0].Preserved { + t.Fatalf("unsupported receipt was not preserved: %+v", preserved.UnknownPackages) + } +} + +func TestObservationTreatsFutureAbsentRootAsEmptyEvidence(t *testing.T) { + root := filepath.Join(resolvedTempDir(t), "future-prefix") + observed, err := Observe(observationInput(root, time.Date(2026, 8, 4, 16, 0, 0, 0, time.UTC))) + if err != nil { + t.Fatal(err) + } + if len(observed.Components) != 0 || len(observed.UnknownPackages) != 0 || + len(observed.ConfiguredRoots) != 1 || observed.ConfiguredRoots[0] != root { + t.Fatalf("absent future root was not retained as empty configured evidence: %+v", observed) + } +} + +func observationInput(root string, observedAt time.Time) ObservationInput { + return ObservationInput{ + ProfileID: "default", TOPSID: testTOPSID, ConfiguredRoots: []string{root}, + SelectedReceipts: map[string]string{}, + QxctlIdentity: Identity{ComponentID: "qxctl", Version: "qxctl-dev", ExecutableDigest: tagged("qxctl")}, + ProviderAvailability: []ProviderAvailability{{ProviderID: "ssiag", Available: true}}, + ObservedAt: observedAt, + } +} + +func receiptV2Digest(t *testing.T, receipt receiptV2) string { + t.Helper() + receipt.ReceiptDigest = "" + value, err := objectWithout(mustJSON(receipt), "receipt_digest") + if err != nil { + t.Fatal(err) + } + digest, err := digestValue(value) + if err != nil { + t.Fatal(err) + } + return digest +} + +func taggedBytes(value []byte) string { + digest := sha256.Sum256(value) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func writeTestFile(t *testing.T, path string, data []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func resolvedTempDir(t *testing.T) string { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return root +} diff --git a/tools/qxctl/internal/knowledgelifecycle/scan_unix.go b/tools/qxctl/internal/knowledgelifecycle/scan_unix.go new file mode 100644 index 0000000..a2956a0 --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/scan_unix.go @@ -0,0 +1,301 @@ +//go:build darwin || linux + +package knowledgelifecycle + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +const ( + maxReceiptModules = 4096 + maxReceiptVersions = 4096 +) + +func scanReceiptCandidates(roots []string) ([]receiptCandidate, error) { + result := make([]receiptCandidate, 0) + for _, root := range roots { + rootCandidates, err := scanReceiptRoot(root) + if err != nil { + return nil, fmt.Errorf("scan configured root %s: %w", root, err) + } + result = append(result, rootCandidates...) + if len(result) > 4096 { + return nil, fmt.Errorf("receipt inventory exceeds 4096 entries") + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].root != result[j].root { + return result[i].root < result[j].root + } + return result[i].relativePath < result[j].relativePath + }) + return result, nil +} + +func scanReceiptRoot(root string) ([]receiptCandidate, error) { + info, err := os.Lstat(root) + if errors.Is(err, os.ErrNotExist) { + return []receiptCandidate{}, nil + } + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, fmt.Errorf("configured root is not a no-follow directory") + } + resolved, err := filepath.EvalSymlinks(root) + if err != nil || filepath.Clean(resolved) != filepath.Clean(root) { + return nil, fmt.Errorf("configured root changes through symbolic-link resolution") + } + rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if err != nil { + return nil, err + } + if err := validateTrustedDirectory(rootFD); err != nil { + _ = unix.Close(rootFD) + return nil, err + } + receiptsFD, exists, err := openTrustedDirectoryPath(rootFD, []string{"share", "symphony", "receipts"}) + _ = unix.Close(rootFD) + if err != nil || !exists { + return nil, err + } + defer unix.Close(receiptsFD) + modules, err := readDirectoryNames(receiptsFD, maxReceiptModules) + if err != nil { + return nil, err + } + result := make([]receiptCandidate, 0) + for _, module := range modules { + if strings.HasPrefix(module, ".") { + continue + } + if !safeToken(module, 256) { + return nil, fmt.Errorf("receipt module directory has an invalid name") + } + moduleFD, err := openTrustedDirectoryAt(receiptsFD, module) + if err != nil { + return nil, fmt.Errorf("open receipt module directory %q: %w", module, err) + } + versions, readErr := readDirectoryNames(moduleFD, maxReceiptVersions) + if readErr != nil { + _ = unix.Close(moduleFD) + return nil, readErr + } + for _, version := range versions { + if strings.HasPrefix(version, ".") { + continue + } + if !safeVersion(version) { + _ = unix.Close(moduleFD) + return nil, fmt.Errorf("receipt version directory has an invalid name") + } + versionFD, err := openTrustedDirectoryAt(moduleFD, version) + if err != nil { + _ = unix.Close(moduleFD) + return nil, fmt.Errorf("open receipt version directory %q: %w", version, err) + } + relative := filepath.ToSlash(filepath.Join("share", "symphony", "receipts", module, version, "install-receipt.json")) + data, exists, readable := readReceiptAt(versionFD) + _ = unix.Close(versionFD) + if !exists { + continue + } + result = append(result, receiptCandidate{ + root: root, relativePath: relative, module: module, version: version, + data: data, readable: readable, + }) + } + _ = unix.Close(moduleFD) + } + return result, nil +} + +func openTrustedDirectoryPath(start int, components []string) (int, bool, error) { + current, err := unix.Dup(start) + if err != nil { + return -1, false, err + } + for _, component := range components { + next, openErr := unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + _ = unix.Close(current) + if errors.Is(openErr, syscall.ENOENT) { + return -1, false, nil + } + if openErr != nil { + return -1, false, openErr + } + if err := validateTrustedDirectory(next); err != nil { + _ = unix.Close(next) + return -1, false, err + } + current = next + } + return current, true, nil +} + +func openTrustedDirectoryAt(parent int, name string) (int, error) { + fd, err := unix.Openat(parent, name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if err != nil { + return -1, err + } + if err := validateTrustedDirectory(fd); err != nil { + _ = unix.Close(fd) + return -1, err + } + return fd, nil +} + +func readDirectoryNames(fd, maximum int) ([]string, error) { + duplicate, err := unix.Dup(fd) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(duplicate), "receipt-directory") + defer file.Close() + entries, err := file.ReadDir(maximum + 1) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if len(entries) > maximum { + return nil, fmt.Errorf("receipt directory exceeds %d entries", maximum) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func readReceiptAt(directory int) ([]byte, bool, bool) { + fd, err := unix.Openat(directory, "install-receipt.json", unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if errors.Is(err, syscall.ENOENT) { + return nil, false, false + } + if err != nil { + return nil, true, false + } + file := os.NewFile(uintptr(fd), "install-receipt.json") + defer file.Close() + if err := validateTrustedRegular(fd); err != nil { + return nil, true, false + } + data, err := io.ReadAll(io.LimitReader(file, maxReceiptBytes+1)) + if err != nil || len(data) == 0 || len(data) > maxReceiptBytes { + return nil, true, false + } + return data, true, true +} + +func validateTrustedDirectory(fd int) error { + var status unix.Stat_t + if err := unix.Fstat(fd, &status); err != nil { + return err + } + if status.Mode&unix.S_IFMT != unix.S_IFDIR || (status.Uid != 0 && status.Uid != uint32(os.Geteuid())) || status.Mode&0o022 != 0 { + return fmt.Errorf("installed directory is not trusted-owner controlled") + } + return nil +} + +func validateTrustedRegular(fd int) error { + var status unix.Stat_t + if err := unix.Fstat(fd, &status); err != nil { + return err + } + if status.Mode&unix.S_IFMT != unix.S_IFREG || (status.Uid != 0 && status.Uid != uint32(os.Geteuid())) || status.Mode&0o022 != 0 { + return fmt.Errorf("installed file is not trusted-owner controlled") + } + return nil +} + +func hashTrustedRelative(root, relative string, maximum int64) (string, uint64, error) { + if !safeRelativePath(relative) { + return "", 0, fmt.Errorf("installed path is unsafe") + } + components := strings.Split(relative, "/") + current, err := unix.Open(root, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if err != nil { + return "", 0, err + } + if err := validateTrustedDirectory(current); err != nil { + _ = unix.Close(current) + return "", 0, err + } + for _, component := range components[:len(components)-1] { + next, openErr := unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + _ = unix.Close(current) + if openErr != nil { + return "", 0, openErr + } + if err := validateTrustedDirectory(next); err != nil { + _ = unix.Close(next) + return "", 0, err + } + current = next + } + fd, err := unix.Openat(current, components[len(components)-1], unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + _ = unix.Close(current) + if err != nil { + return "", 0, err + } + file := os.NewFile(uintptr(fd), components[len(components)-1]) + defer file.Close() + if err := validateTrustedRegular(fd); err != nil { + return "", 0, err + } + info, err := file.Stat() + if err != nil || info.Size() < 0 || info.Size() > maximum { + return "", 0, fmt.Errorf("installed file exceeds its size bound") + } + hash := sha256.New() + written, err := io.Copy(hash, io.LimitReader(file, maximum+1)) + if err != nil || written != info.Size() { + return "", 0, fmt.Errorf("installed file changed while hashing") + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), uint64(written), nil +} + +func hashRegularFile(path string, maximum int64) (string, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return "", err + } + file := os.NewFile(uintptr(fd), path) + defer file.Close() + var status unix.Stat_t + if err := unix.Fstat(fd, &status); err != nil || status.Mode&unix.S_IFMT != unix.S_IFREG || status.Size < 0 || status.Size > maximum { + return "", fmt.Errorf("executable is not a bounded regular file") + } + hash := sha256.New() + written, err := io.Copy(hash, io.LimitReader(file, maximum+1)) + if err != nil || written != status.Size { + return "", fmt.Errorf("executable changed while hashing") + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil +} + +func kernelABI() string { + var name unix.Utsname + if err := unix.Uname(&name); err != nil { + return "uname:unavailable" + } + bytes := make([]byte, 0, len(name.Release)) + for _, value := range name.Release { + if value == 0 { + break + } + bytes = append(bytes, byte(value)) + } + digest := sha256.Sum256(bytes) + return "uname:" + hex.EncodeToString(digest[:]) +} diff --git a/tools/qxctl/internal/knowledgelifecycle/scan_unsupported.go b/tools/qxctl/internal/knowledgelifecycle/scan_unsupported.go new file mode 100644 index 0000000..a60172b --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/scan_unsupported.go @@ -0,0 +1,19 @@ +//go:build !darwin && !linux + +package knowledgelifecycle + +import "fmt" + +func scanReceiptCandidates([]string) ([]receiptCandidate, error) { + return nil, fmt.Errorf("configured-root observation is unsupported on this operating system") +} + +func hashTrustedRelative(string, string, int64) (string, uint64, error) { + return "", 0, fmt.Errorf("configured-root observation is unsupported on this operating system") +} + +func hashRegularFile(string, int64) (string, error) { + return "", fmt.Errorf("configured-root observation is unsupported on this operating system") +} + +func kernelABI() string { return "unsupported" } diff --git a/tools/qxctl/internal/knowledgelifecycle/state_unix.go b/tools/qxctl/internal/knowledgelifecycle/state_unix.go new file mode 100644 index 0000000..405563b --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/state_unix.go @@ -0,0 +1,296 @@ +//go:build darwin || linux + +package knowledgelifecycle + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +func (s *Store) withProfileLock(exclusive bool, operation func(*os.File) error) error { + directory, err := openProfileDirectory(s.stateRoot, s.topsID) + if err != nil { + return err + } + defer directory.Close() + lock, err := acquireProfileLock(directory, exclusive) + if err != nil { + return err + } + defer lock.Close() + return operation(directory) +} + +func openProfileDirectory(root, topsID string) (*os.File, error) { + current, err := openStateRootNoFollow(root) + if err != nil { + return nil, fmt.Errorf("open lifecycle state root: %w", err) + } + if err := validateOwnedDirectory(current, false); err != nil { + _ = unix.Close(current) + return nil, fmt.Errorf("validate lifecycle state root: %w", err) + } + for _, component := range []string{"symphony", topsID, "qxctl", "knowledge", "lifecycle", "profiles"} { + next, openErr := unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if errors.Is(openErr, syscall.ENOENT) { + if mkdirErr := unix.Mkdirat(current, component, 0o700); mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) { + _ = unix.Close(current) + return nil, fmt.Errorf("create lifecycle state component %q: %w", component, mkdirErr) + } + if syncErr := unix.Fsync(current); syncErr != nil { + _ = unix.Close(current) + return nil, fmt.Errorf("durably create lifecycle state component %q: %w", component, syncErr) + } + next, openErr = unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + } + _ = unix.Close(current) + if openErr != nil { + return nil, fmt.Errorf("open lifecycle state component %q: %w", component, openErr) + } + if err := validateOwnedDirectory(next, true); err != nil { + _ = unix.Close(next) + return nil, fmt.Errorf("validate lifecycle state component %q: %w", component, err) + } + current = next + } + return os.NewFile(uintptr(current), filepath.Join(root, "symphony", topsID, "qxctl/knowledge/lifecycle/profiles")), nil +} + +func openStateRootNoFollow(root string) (int, error) { + clean := filepath.Clean(root) + if !filepath.IsAbs(clean) || clean == string(os.PathSeparator) { + return -1, fmt.Errorf("state root must be an absolute descendant path") + } + current, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if err != nil { + return -1, err + } + for _, component := range strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator)) { + if component == "" || component == "." || component == ".." { + _ = unix.Close(current) + return -1, fmt.Errorf("state root contains an unsafe component") + } + next, openErr := unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + if errors.Is(openErr, syscall.ENOENT) { + if mkdirErr := unix.Mkdirat(current, component, 0o700); mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) { + _ = unix.Close(current) + return -1, mkdirErr + } + if syncErr := unix.Fsync(current); syncErr != nil { + _ = unix.Close(current) + return -1, syncErr + } + next, openErr = unix.Openat(current, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0) + } + _ = unix.Close(current) + if openErr != nil { + return -1, openErr + } + current = next + } + return current, nil +} + +func validateOwnedDirectory(fd int, forcePrivate bool) error { + var status unix.Stat_t + if err := unix.Fstat(fd, &status); err != nil { + return err + } + if status.Mode&unix.S_IFMT != unix.S_IFDIR || status.Uid != uint32(os.Geteuid()) || status.Mode&0o022 != 0 { + return fmt.Errorf("state directory is not effective-user-owned and protected") + } + if forcePrivate { + return unix.Fchmod(fd, 0o700) + } + return nil +} + +type profileLock struct{ file *os.File } + +func acquireProfileLock(directory *os.File, exclusive bool) (*profileLock, error) { + fd, err := unix.Openat(int(directory.Fd()), "profiles.lock", unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err != nil { + return nil, fmt.Errorf("open lifecycle profile lock: %w", err) + } + file := os.NewFile(uintptr(fd), "profiles.lock") + closeWith := func(cause error) (*profileLock, error) { + _ = file.Close() + return nil, cause + } + if err := validateOwnedRegular(fd); err != nil { + return closeWith(fmt.Errorf("validate lifecycle profile lock: %w", err)) + } + if err := unix.Fchmod(fd, 0o600); err != nil { + return closeWith(fmt.Errorf("restrict lifecycle profile lock: %w", err)) + } + mode := unix.LOCK_SH | unix.LOCK_NB + if exclusive { + mode = unix.LOCK_EX | unix.LOCK_NB + } + if err := unix.Flock(fd, mode); err != nil { + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return closeWith(fmt.Errorf("lifecycle profile store is busy")) + } + return closeWith(fmt.Errorf("lock lifecycle profile store: %w", err)) + } + return &profileLock{file: file}, nil +} + +func (lock *profileLock) Close() error { + if lock == nil || lock.file == nil { + return nil + } + fd := int(lock.file.Fd()) + err := errors.Join(unix.Flock(fd, unix.LOCK_UN), lock.file.Close()) + lock.file = nil + return err +} + +func validateOwnedRegular(fd int) error { + var status unix.Stat_t + if err := unix.Fstat(fd, &status); err != nil { + return err + } + if status.Mode&unix.S_IFMT != unix.S_IFREG || status.Uid != uint32(os.Geteuid()) || status.Mode&0o077 != 0 { + return fmt.Errorf("state file is not effective-user-owned and owner-only") + } + return nil +} + +func readProfileFile(directory *os.File, profileID string) ([]byte, bool, error) { + name := profileFileName(profileID) + fd, err := unix.Openat(int(directory.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if errors.Is(err, syscall.ENOENT) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("open lifecycle profile: %w", err) + } + file := os.NewFile(uintptr(fd), name) + defer file.Close() + if err := validateOwnedRegular(fd); err != nil { + return nil, false, fmt.Errorf("validate lifecycle profile: %w", err) + } + data, err := io.ReadAll(io.LimitReader(file, maxProfileBytes+1)) + if err != nil { + return nil, false, fmt.Errorf("read lifecycle profile: %w", err) + } + if len(data) > maxProfileBytes { + return nil, false, fmt.Errorf("lifecycle profile exceeds %d bytes", maxProfileBytes) + } + return data, true, nil +} + +type listedProfileFile struct { + name string + data []byte +} + +func listProfileFiles(directory *os.File) ([]listedProfileFile, error) { + duplicate, err := unix.Dup(int(directory.Fd())) + if err != nil { + return nil, fmt.Errorf("duplicate lifecycle profile directory: %w", err) + } + file := os.NewFile(uintptr(duplicate), "profiles") + defer file.Close() + entries, err := file.ReadDir(maxProfiles + 2) + if err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("enumerate lifecycle profiles: %w", err) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if name == "profiles.lock" || strings.HasPrefix(name, ".") { + continue + } + if !strings.HasPrefix(name, "profile-") || !strings.HasSuffix(name, ".json") || len(name) != len("profile-")+64+len(".json") { + return nil, fmt.Errorf("lifecycle profile directory contains an unknown object") + } + names = append(names, name) + } + if len(names) > maxProfiles { + return nil, fmt.Errorf("lifecycle profile count exceeds %d", maxProfiles) + } + sort.Strings(names) + result := make([]listedProfileFile, 0, len(names)) + for _, name := range names { + fd, err := unix.Openat(int(directory.Fd()), name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, fmt.Errorf("open listed lifecycle profile: %w", err) + } + profileFile := os.NewFile(uintptr(fd), name) + if err := validateOwnedRegular(fd); err != nil { + _ = profileFile.Close() + return nil, fmt.Errorf("validate listed lifecycle profile: %w", err) + } + data, err := io.ReadAll(io.LimitReader(profileFile, maxProfileBytes+1)) + _ = profileFile.Close() + if err != nil || len(data) > maxProfileBytes { + return nil, fmt.Errorf("read listed lifecycle profile failed or exceeded its bound") + } + result = append(result, listedProfileFile{name: name, data: data}) + } + return result, nil +} + +func writeProfileFile(directory *os.File, profileID string, data []byte) error { + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return fmt.Errorf("generate lifecycle profile temporary name: %w", err) + } + name := profileFileName(profileID) + temp := "." + name + ".tmp-" + hex.EncodeToString(random) + fd, err := unix.Openat(int(directory.Fd()), temp, unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("create lifecycle profile temporary file: %w", err) + } + file := os.NewFile(uintptr(fd), temp) + cleanup := func() { + _ = file.Close() + _ = unix.Unlinkat(int(directory.Fd()), temp, 0) + } + if err := validateOwnedRegular(fd); err != nil { + cleanup() + return err + } + if _, err := file.Write(data); err != nil { + cleanup() + return fmt.Errorf("write lifecycle profile: %w", err) + } + if err := file.Sync(); err != nil { + cleanup() + return fmt.Errorf("durably write lifecycle profile: %w", err) + } + if err := file.Close(); err != nil { + _ = unix.Unlinkat(int(directory.Fd()), temp, 0) + return fmt.Errorf("close lifecycle profile temporary file: %w", err) + } + if err := unix.Renameat(int(directory.Fd()), temp, int(directory.Fd()), name); err != nil { + _ = unix.Unlinkat(int(directory.Fd()), temp, 0) + return fmt.Errorf("atomically replace lifecycle profile: %w", err) + } + if err := unix.Fsync(int(directory.Fd())); err != nil { + return fmt.Errorf("durably commit lifecycle profile directory: %w", err) + } + return nil +} + +func removeProfileFile(directory *os.File, profileID string) error { + if err := unix.Unlinkat(int(directory.Fd()), profileFileName(profileID), 0); err != nil { + return fmt.Errorf("remove lifecycle profile: %w", err) + } + if err := unix.Fsync(int(directory.Fd())); err != nil { + return fmt.Errorf("durably commit lifecycle profile removal: %w", err) + } + return nil +} diff --git a/tools/qxctl/internal/knowledgelifecycle/state_unsupported.go b/tools/qxctl/internal/knowledgelifecycle/state_unsupported.go new file mode 100644 index 0000000..89bfd88 --- /dev/null +++ b/tools/qxctl/internal/knowledgelifecycle/state_unsupported.go @@ -0,0 +1,33 @@ +//go:build !darwin && !linux + +package knowledgelifecycle + +import ( + "fmt" + "os" +) + +func (s *Store) withProfileLock(bool, func(*os.File) error) error { + return fmt.Errorf("lifecycle profile state is unsupported on this operating system") +} + +func readProfileFile(*os.File, string) ([]byte, bool, error) { + return nil, false, fmt.Errorf("lifecycle profile state is unsupported on this operating system") +} + +type listedProfileFile struct { + name string + data []byte +} + +func listProfileFiles(*os.File) ([]listedProfileFile, error) { + return nil, fmt.Errorf("lifecycle profile state is unsupported on this operating system") +} + +func writeProfileFile(*os.File, string, []byte) error { + return fmt.Errorf("lifecycle profile state is unsupported on this operating system") +} + +func removeProfileFile(*os.File, string) error { + return fmt.Errorf("lifecycle profile state is unsupported on this operating system") +} diff --git a/tools/symphony-validator/MANIFEST.md b/tools/symphony-validator/MANIFEST.md index 693148e..39ebe17 100644 --- a/tools/symphony-validator/MANIFEST.md +++ b/tools/symphony-validator/MANIFEST.md @@ -42,7 +42,7 @@ Structured JSON and Markdown projections remain deferred. Runtime source/AST cal ## Canonical JSON Boundary -The artifact checker recognizes exactly 96 canonical JSON paths: 28 STAV v1 schemas/fixtures, twenty-four common SKV process/descriptor/receipt/binding/proposal/provider-evidence/reconciliation/session/lifecycle schemas (twenty-three v1 and one v2), three SSIAG authorization schemas, four SKVI operation/result schemas, five SCLV v3 operation/result schemas, six SACV v1 operation/result schemas, eight SODV operational schemas, and eighteen SSFV v1/v2 schemas. It does not authorize a directory prefix, generated projection, or new JSON artifact by extension. +The artifact checker recognizes exactly 98 canonical JSON paths: 28 STAV v1 schemas/fixtures, twenty-six common SKV process/descriptor/receipt/binding/proposal/provider-evidence/reconciliation/session/lifecycle schemas (twenty-five v1 and one v2), three SSIAG authorization schemas, four SKVI operation/result schemas, five SCLV v3 operation/result schemas, six SACV v1 operation/result schemas, eight SODV operational schemas, and eighteen SSFV v1/v2 schemas. It does not authorize a directory prefix, generated projection, or new JSON artifact by extension. The contract-shape and canonical-surface checks require the SSFV Contract Quad, namespace and feature registries, deterministic feature-file format, independently installed engine Contract Quad/build surface, and qxctl grammar/client anchors. The validator confirms anchors, presence, SKVI coverage, and exact JSON allowlisting; it does not decide feature-worthiness or duplicate the implemented SSFV engine's distributed-record parser. diff --git a/tools/symphony-validator/SPEC.md b/tools/symphony-validator/SPEC.md index b9ad8f1..d7c93c4 100644 --- a/tools/symphony-validator/SPEC.md +++ b/tools/symphony-validator/SPEC.md @@ -95,7 +95,7 @@ Stale names (e.g. `legacy node execution label`, `legacy native hot-path label`, ## Allowlist Behavior Allowlists must never become silent bypasses. Every allowlist entry must produce evidence in JSON and Markdown. -The Architect-ratified STAV v1 JSON Schema/conformance fixtures, twenty-four common SKV process/descriptor/receipt/binding/proposal/provider-evidence/reconciliation/session/lifecycle schemas (twenty-three v1 and one v2), three SSIAG authorization schemas, four SKVI operation/result schemas, five SCLV v3 operation/result schemas, six SACV v1 operation/result schemas, eight SODV operational schemas, and eighteen SSFV v1/v2 schemas are canonical protocol truth, not generated projections. The artifact checker may allow only their 96 exact paths and must emit `artifact.canonical_json_authorized` evidence for every encountered file with `knowledge/stav/SPEC.md`, `knowledge/SPEC.md`, `knowledge/ssiag/SPEC.md`, `knowledge/skvi/SPEC.md`, `knowledge/sclv/SPEC.md`, `knowledge/sacv/SPEC.md`, `knowledge/sodv/SPEC.md`, or `knowledge/ssfv/SPEC.md` authority as applicable. Prefix or extension-wide JSON allowlisting is prohibited; any new canonical JSON artifact requires an explicit contract and validator update. +The Architect-ratified STAV v1 JSON Schema/conformance fixtures, twenty-six common SKV process/descriptor/receipt/binding/proposal/provider-evidence/reconciliation/session/lifecycle schemas (twenty-five v1 and one v2), three SSIAG authorization schemas, four SKVI operation/result schemas, five SCLV v3 operation/result schemas, six SACV v1 operation/result schemas, eight SODV operational schemas, and eighteen SSFV v1/v2 schemas are canonical protocol truth, not generated projections. The artifact checker may allow only their 98 exact paths and must emit `artifact.canonical_json_authorized` evidence for every encountered file with `knowledge/stav/SPEC.md`, `knowledge/SPEC.md`, `knowledge/ssiag/SPEC.md`, `knowledge/skvi/SPEC.md`, `knowledge/sclv/SPEC.md`, `knowledge/sacv/SPEC.md`, `knowledge/sodv/SPEC.md`, or `knowledge/ssfv/SPEC.md` authority as applicable. Prefix or extension-wide JSON allowlisting is prohibited; any new canonical JSON artifact requires an explicit contract and validator update. ### SSFV Contract Boundary diff --git a/tools/symphony-validator/src/artifacts.cpp b/tools/symphony-validator/src/artifacts.cpp index 624c7b2..171e97f 100644 --- a/tools/symphony-validator/src/artifacts.cpp +++ b/tools/symphony-validator/src/artifacts.cpp @@ -11,7 +11,7 @@ namespace fs = std::filesystem; bool is_authorized_canonical_json(const std::string& relative_path) { // Exact, Architect-ratified STAV v1, common SKV, SKVI, SCLV, SACV, SODV, and SSFV protocol artifacts. Directory-prefix // allowlisting would silently admit unreviewed JSON and is prohibited. - static const std::array authorized_paths = { + static const std::array authorized_paths = { "knowledge/stav/schemas/v1/common.schema.json", "knowledge/stav/schemas/v1/candidate.schema.json", "knowledge/stav/schemas/v1/event.schema.json", @@ -56,6 +56,8 @@ bool is_authorized_canonical_json(const std::string& relative_path) { "knowledge/schemas/v1/session-journal.schema.json", "knowledge/schemas/v1/session-result.schema.json", "knowledge/schemas/v1/session-transition-result.schema.json", + "knowledge/schemas/v1/lifecycle-profile-input.schema.json", + "knowledge/schemas/v1/lifecycle-profile.schema.json", "knowledge/schemas/v1/lifecycle-desired-state.schema.json", "knowledge/schemas/v1/lifecycle-observation.schema.json", "knowledge/schemas/v1/lifecycle-plan-command.schema.json", diff --git a/tools/symphony-validator/tests/smoke.sh b/tools/symphony-validator/tests/smoke.sh index 4cb1bdc..8f23250 100755 --- a/tools/symphony-validator/tests/smoke.sh +++ b/tools/symphony-validator/tests/smoke.sh @@ -176,8 +176,8 @@ if ! printf '%s\n' "$OUT_REPO" | grep "caller_authority.scan_complete " | grep " echo "error: current repo missing expected caller_authority.scan_complete status or findings=0" exit 1 fi -if [ "$(printf '%s\n' "$OUT_REPO" | grep -c "artifact.canonical_json_authorized")" -ne 96 ]; then - echo "error: current repo should authorize exactly the 28 STAV, 24 common SKV, 3 SSIAG, 4 SKVI, 5 SCLV, 6 SACV, 8 SODV, and 18 SSFV JSON artifacts" +if [ "$(printf '%s\n' "$OUT_REPO" | grep -c "artifact.canonical_json_authorized")" -ne 98 ]; then + echo "error: current repo should authorize exactly the 28 STAV, 26 common SKV, 3 SSIAG, 4 SKVI, 5 SCLV, 6 SACV, 8 SODV, and 18 SSFV JSON artifacts" exit 1 fi if ! printf '%s\n' "$OUT_REPO" | grep "sodv.releases.scan_complete records=3 transactions=1 violations=0" >/dev/null; then