Compare commits

..

6 Commits

Author SHA1 Message Date
Jarvis
4d990eee7c fix(fleet): fail closed on persona overrides
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
Jarvis
ed940f15f3 test(fleet): make unreadable persona fixture deterministic
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
Jarvis
a262f63d3d chore(docs): normalize fleet task table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
Jarvis
5aef8ed690 feat(fleet): add shared role semantics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
Jarvis
1865f73718 chore(orchestrator): reassign FCM-M1-002 to fresh worker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
Jarvis
13684d452e chore(orchestrator): start FCM-M1-002 shared role resolution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:15:28 -05:00
8 changed files with 28 additions and 434 deletions

View File

@@ -7,8 +7,7 @@ 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. The current executable implementation and per-artifact outcomes are recorded in
[the disposition evidence](./migration/example-profile-disposition.md).
parallel resolver.
## Examples

View File

@@ -1,52 +0,0 @@
# 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.

View File

@@ -96,22 +96,12 @@ DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Cov
- marker-defined identity and domain metadata are revalidated on the second read;
- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas.
Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record
returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could
inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could
miss an override created before baseline fallback, and inherited plain-object names such as `constructor`
could corrupt alias/authority lookup. Merge remained held.
Final independent finding-specific rereview: **APPROVE**. The reviewer verified exact absolute and
relative dangling-ancestor traversal, cached-missing revalidation, second-read marker identity, and
async/sync/list/status behavior. The source remained at remote head `1c41adad…` during remediation;
a fresh pushed head still requires terminal-green CI and durable exact-head reviewer-of-record before
merge.
Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without
expanding card scope. Explicit first markers now own identity and filename fallback applies only to
markerless contracts; every second read rejects a newly introduced conflicting marker regardless of
cached classification; cached async resolution re-scans the override layer immediately before every
baseline fallback; alias and authority registries require own-property matches. Current uncommitted
evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package
**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent
finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct
adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit
exact-head gates remain required.
## Risks and boundaries

View File

@@ -1,37 +0,0 @@
# 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.
- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1).

View File

@@ -71,7 +71,7 @@ describe('FCM class canonicalization and authority', () => {
});
});
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron'])(
'does not infer an alias for %s',
(requested: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
@@ -81,28 +81,6 @@ describe('FCM class canonicalization and authority', () => {
},
);
it('resolves inherited-property class names without granting protected authority', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'constructor.md'),
overridePersona('constructor', 'custom'),
'utf8',
);
const resolved = await resolvePersona('constructor', { rolesDir, overrideDir });
expect(resolved).toMatchObject({
requestedClass: 'constructor',
canonicalClass: 'constructor',
klass: 'constructor',
layer: 'override',
});
expect(authorityForCanonicalClass('constructor')).toMatchObject({
mayMerge: false,
mayIssueValidationCertificate: false,
mayOrchestrate: false,
});
});
it('canonicalizes before override lookup and lets the canonical override win', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
@@ -280,23 +258,6 @@ describe('resolvePersona — override wins', () => {
).toBe(false);
});
it('rejects a canonical filename whose explicit marker names another class', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull();
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false);
expect(
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'),
).toBe(false);
});
it('fails closed if a marker-defined override becomes unreadable after extraction', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'engineering.md');
@@ -333,26 +294,6 @@ describe('resolvePersona — override wins', () => {
expect(status.get('mascot')?.status).toBe('custom');
});
it('fails closed if a markerless cached override gains a conflicting marker', async () => {
await mkdir(overrideDir, { recursive: true });
const overrideFile = join(overrideDir, 'merge-gate.md');
await writeFile(overrideFile, '# markerless merge gate\n', 'utf8');
await writeFile(
join(rolesDir, 'merge-gate.md'),
baselinePersona('merge-gate', 'governance'),
'utf8',
);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
expect(
await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }),
).toBeNull();
});
it('fails closed asynchronously when the override directory cannot be scanned', async () => {
await writeFile(overrideDir, 'not a directory', 'utf8');
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
@@ -423,27 +364,6 @@ describe('resolvePersona — override wins', () => {
).toBeNull();
});
it('revalidates a cached scanned override before baseline fallback', async () => {
await mkdir(overrideDir, { recursive: true });
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
expect(over.scanState).toBe('scanned');
expect(over.classes.size).toBe(0);
await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering'));
const resolved = await resolvePersonaFrom('code', {
rolesDir,
overrideDir,
base,
over,
});
expect(resolved?.layer).toBe('override');
expect(resolved?.file).toBe(join(overrideDir, 'engineering.md'));
});
it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => {
const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir });
const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir });

View File

@@ -71,9 +71,7 @@ export interface CanonicalRoleClass {
/** Canonicalize only the three explicitly approved compatibility aliases. */
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
const requested = requestedClass.trim();
const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES]
: requested;
const canonical = ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] ?? requested;
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
}
@@ -123,9 +121,7 @@ export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuth
/** Return protected authority for an already-canonical class; custom classes get none. */
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
: NO_PROTECTED_AUTHORITY;
return ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass] ?? NO_PROTECTED_AUTHORITY;
}
/** One discovered persona file (a single role contract on disk). */
@@ -290,6 +286,15 @@ function hasBrokenSymlinkInPathSync(path: string): boolean {
return false;
}
async function missingScanStillValid(dir: string): Promise<boolean> {
try {
await readdir(dir);
return false;
} catch (error) {
return (await classifyScanFailure(dir, error)) === 'missing';
}
}
function isMissingPathError(error: unknown): boolean {
return (
typeof error === 'object' &&
@@ -309,9 +314,10 @@ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: stri
const { classes, byClass } = acc;
if (entry === 'LIBRARY.md') return;
// An explicit first marker owns identity. Filename identity is a fallback only
// for markerless compatibility contracts such as planner.md.
// A successfully read role file is addressable by filename, including
// marker-less compatibility contracts such as planner.md.
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
@@ -324,9 +330,8 @@ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: stri
markerDefined: true,
...(domain ? { domain } : {}),
});
} else {
classes.add(stem);
if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
} else if (!byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
}
}
@@ -381,10 +386,6 @@ async function readPersonaFromLayer(
const byName = join(dir, `${canonicalClass}.md`);
try {
const content = await readFile(byName, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
@@ -404,7 +405,7 @@ async function readPersonaFromLayer(
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
@@ -441,10 +442,6 @@ function readPersonaFromLayerSync(
const byName = join(dir, `${canonicalClass}.md`);
try {
const content = readFileSync(byName, 'utf8');
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
@@ -464,7 +461,7 @@ function readPersonaFromLayerSync(
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
| RegExpExecArray
| undefined;
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null;
const dm = DOMAIN_MARKER.exec(content);
return {
requestedClass,
@@ -515,22 +512,10 @@ export async function resolvePersonaFrom(
const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
if (override) return override;
// An observed explicit override that cannot resolve/read shadows the baseline.
// An explicit canonical override that cannot be resolved/read must shadow the
// baseline fail-closed; only an absent override may fall back.
if (overrideShadowsBaseline(over, klass)) return null;
// Cached scans are only snapshots. Re-scan immediately before baseline fallback
// so a newly created canonical or marker-defined override cannot be skipped.
const currentOver = await extractClassesFromDir(overrideDir);
const currentOverride = await readPersonaFromLayer(
requested,
klass,
overrideDir,
currentOver,
'override',
);
if (currentOverride) return currentOverride;
if (overrideShadowsBaseline(currentOver, klass)) return null;
if (currentOver.scanState === 'error') return null;
if (over.scanState === 'missing' && !(await missingScanStillValid(overrideDir))) return null;
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
}

View File

@@ -1,90 +0,0 @@
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<void> => {
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<void> => {
if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true });
temporaryFleet = undefined;
});
it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise<void> => {
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<void> => {
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/);
});
});

View File

@@ -1,121 +0,0 @@
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<readonly ShippedFleetArtifactDisposition[]> {
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<void> {
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<Set<string>> {
const directories = ['examples', 'profiles', 'services'];
const paths = new Set<string>();
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;
}