diff --git a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md index 318b4e9..d1ccf6e 100644 --- a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md +++ b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md @@ -7,7 +7,8 @@ The v2 compiler may not silently accept an unresolved class. Before M1 exits, ev below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement/deprecation note. Class resolution must use the existing profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a -parallel resolver. +parallel resolver. The current executable implementation and per-artifact outcomes are recorded in +[the disposition evidence](./migration/example-profile-disposition.md). ## Examples diff --git a/docs/fleet/migration/example-profile-disposition.md b/docs/fleet/migration/example-profile-disposition.md new file mode 100644 index 0000000..dd7ba44 --- /dev/null +++ b/docs/fleet/migration/example-profile-disposition.md @@ -0,0 +1,52 @@ +# Executable Fleet Example, Profile, and Service-Preset Dispositions + +**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence + +This document records the executable disposition for every currently shipped fleet YAML artifact. +The authoritative baseline classification remains the +[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is +`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML +artifact is added, removed, or left without one of the dispositions below. + +## Disposition rules + +- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must + declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2 + roster or given inferred aliases. +- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared + baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes. +- **Canonical service policy:** the artifact is loaded through the operator-interaction service + policy reader and provisioned with a generic supplied identity. It validates its runtime, model, + reasoning, and legacy tool-policy compatibility without hardcoding a product identity. + +No artifact is retired in this card. A later retirement requires both a replacement link and a +visible deprecation note; the executable guard must then record the new disposition before the +artifact can be removed. + +## Shipped artifacts + +| Artifact | Disposition | Executable path | Compatibility notes | +| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ | +| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. | +| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. | +| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. | +| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. | +| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. | +| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. | +| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. | +| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. | +| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. | +| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. | +| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. | +| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | +| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | + +## Running the guard + +```bash +pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts +``` + +The guard is intentionally limited to shipped assets and validation. It does not generate +environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an +agent. diff --git a/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md new file mode 100644 index 0000000..9e34f98 --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md @@ -0,0 +1,36 @@ +# FCM-M1-003 — Executable example/profile/service-preset dispositions + +- **Task / issue:** FCM-M1-003 / #758 +- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97` +- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link. +- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler. +- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`. +- **Budget:** 20K card allocation; use focused package tests before full package validation. + +## Plan + +1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests. +2. Add failing behavior tests covering all listed artifacts and their documented disposition. +3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver. +4. Run focused and package quality gates; obtain independent code and security review. +5. Commit, queue-guard, push, open one `main` PR with `Refs #758`. + +## Progress + +- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree. +- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides. +- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials. +- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues. + +## Risks / decisions + +- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002. +- `docs/TASKS.md` is orchestrator-owned and will not be edited. + +## Verification evidence + +- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests). +- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests). +- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS. +- Diff gate: `git diff --check` — PASS. +- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings. diff --git a/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts b/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts new file mode 100644 index 0000000..d2595d2 --- /dev/null +++ b/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts @@ -0,0 +1,90 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + SHIPPED_FLEET_ARTIFACT_DISPOSITIONS, + validateShippedFleetArtifactDispositions, +} from './example-profile-dispositions.js'; + +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); + +const EXPECTED_DISPOSITIONS = [ + 'examples/coding.yaml:v1-fixture', + 'examples/general.yaml:v1-fixture', + 'examples/hybrid.yaml:v1-fixture', + 'examples/local-canary.yaml:v1-fixture', + 'examples/minimal.yaml:v1-fixture', + 'examples/operator-interaction.yaml:v1-fixture', + 'examples/research.yaml:v1-fixture', + 'profiles/business.yaml:canonical-profile', + 'profiles/marketing.yaml:canonical-profile', + 'profiles/personal-assistant.yaml:canonical-profile', + 'profiles/research.yaml:canonical-profile', + 'profiles/software-delivery.yaml:canonical-profile', + 'services/operator-interaction.yaml:canonical-service-policy', +]; + +const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map( + ({ path, disposition }): string => `${path}:${disposition}`, +); + +describe('shipped fleet example/profile/service disposition validation', (): void => { + it('enumerates every inventory artifact with an explicit executable disposition', (): void => { + expect(declared).toEqual(EXPECTED_DISPOSITIONS); + }); + + it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise => { + const results = await validateShippedFleetArtifactDispositions({ frameworkFleet }); + + expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual( + EXPECTED_DISPOSITIONS, + ); + expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength( + 7, + ); + expect( + results.filter(({ disposition }): boolean => disposition === 'canonical-profile'), + ).toHaveLength(5); + expect( + results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'), + ).toMatchObject({ + disposition: 'canonical-service-policy', + }); + }); + + let temporaryFleet: string | undefined; + afterEach(async (): Promise => { + if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true }); + temporaryFleet = undefined; + }); + + it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise => { + temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-')); + await cp(frameworkFleet, temporaryFleet, { recursive: true }); + await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n'); + + await expect( + validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }), + ).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i); + }); + + it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise => { + temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-')); + await cp(frameworkFleet, temporaryFleet, { recursive: true }); + const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml'); + const fixture = await readFile(fixturePath, 'utf8'); + await writeFile(fixturePath, fixture.replace(/^version: 1\n/, '')); + + await expect( + validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }), + ).rejects.toThrow(/Fleet roster version must be 1/); + }); +}); diff --git a/packages/mosaic/src/fleet/example-profile-dispositions.ts b/packages/mosaic/src/fleet/example-profile-dispositions.ts new file mode 100644 index 0000000..c87fb33 --- /dev/null +++ b/packages/mosaic/src/fleet/example-profile-dispositions.ts @@ -0,0 +1,121 @@ +import { readdir } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { loadFleetRoster } from '../commands/fleet.js'; +import { loadProfiles } from '../commands/fleet-profiles.js'; +import { + provisionInteractionService, + readInteractionServiceProfile, +} from './interaction-service-profile.js'; + +export type FleetArtifactDisposition = + | 'v1-fixture' + | 'canonical-profile' + | 'canonical-service-policy'; + +export interface ShippedFleetArtifactDisposition { + readonly path: string; + readonly disposition: FleetArtifactDisposition; +} + +export interface ValidateShippedFleetArtifactDispositionsOptions { + readonly frameworkFleet: string; + readonly rolesDir?: string; + readonly overrideDir?: string; +} + +/** + * The M0 inventory in executable form. Every shipped fleet YAML asset is either + * a deliberately retained v1 fixture or validated through its canonical loader. + */ +export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [ + { path: 'examples/coding.yaml', disposition: 'v1-fixture' }, + { path: 'examples/general.yaml', disposition: 'v1-fixture' }, + { path: 'examples/hybrid.yaml', disposition: 'v1-fixture' }, + { path: 'examples/local-canary.yaml', disposition: 'v1-fixture' }, + { path: 'examples/minimal.yaml', disposition: 'v1-fixture' }, + { path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' }, + { path: 'examples/research.yaml', disposition: 'v1-fixture' }, + { path: 'profiles/business.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/marketing.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/research.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' }, + { path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' }, +]; + +/** + * Fail closed when a fleet YAML asset is added or removed without a disposition. + * This keeps legacy v1 compatibility explicit instead of silently accepting new + * unresolved classes outside the shared resolver. + */ +export async function validateShippedFleetArtifactDispositions( + options: ValidateShippedFleetArtifactDispositionsOptions, +): Promise { + await assertEveryShippedArtifactIsDeclared(options.frameworkFleet); + + const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'v1-fixture', + ); + for (const artifact of examples) { + const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path)); + if (roster.version !== 1) { + throw new Error(`v1 fixture ${artifact.path} must declare version: 1`); + } + } + + const profilesDir = join(options.frameworkFleet, 'profiles'); + const profiles = await loadProfiles({ + profilesDir, + rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'), + overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'), + }); + const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'canonical-profile', + ).map(({ path }): string => basename(path, '.yaml')); + const resolvedProfiles = new Set(profiles.map(({ id }): string => id)); + for (const profileId of declaredProfiles) { + if (!resolvedProfiles.has(profileId)) { + throw new Error(`declared canonical profile ${profileId} did not resolve`); + } + } + + const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'canonical-service-policy', + ); + for (const artifact of servicePolicies) { + const serviceProfile = await readInteractionServiceProfile( + join(options.frameworkFleet, artifact.path), + ); + provisionInteractionService(serviceProfile, { agentName: 'interaction-example' }); + } + + return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS; +} + +async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise { + const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path)); + const shipped = await listShippedFleetArtifactPaths(frameworkFleet); + + for (const path of shipped) { + if (!declared.has(path)) { + throw new Error(`undeclared shipped fleet artifact: ${path}`); + } + } + for (const path of declared) { + if (!shipped.has(path)) { + throw new Error(`declared shipped fleet artifact is missing: ${path}`); + } + } +} + +async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise> { + const directories = ['examples', 'profiles', 'services']; + const paths = new Set(); + for (const directory of directories) { + const files = await readdir(join(frameworkFleet, directory)); + for (const file of files) { + if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`); + } + } + return paths; +}