Compare commits
2 Commits
4d990eee7c
...
test/758-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb3f5a2ca | ||
| a5e8e55401 |
@@ -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
|
||||
|
||||
|
||||
52
docs/fleet/migration/example-profile-disposition.md
Normal file
52
docs/fleet/migration/example-profile-disposition.md
Normal file
@@ -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.
|
||||
@@ -96,12 +96,22 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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).
|
||||
@@ -71,7 +71,7 @@ describe('FCM class canonicalization and authority', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron'])(
|
||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
|
||||
'does not infer an alias for %s',
|
||||
(requested: string) => {
|
||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||
@@ -81,6 +81,28 @@ 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(
|
||||
@@ -258,6 +280,23 @@ 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');
|
||||
@@ -294,6 +333,26 @@ 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();
|
||||
@@ -364,6 +423,27 @@ 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 });
|
||||
|
||||
@@ -71,7 +71,9 @@ export interface CanonicalRoleClass {
|
||||
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
||||
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
||||
const requested = requestedClass.trim();
|
||||
const canonical = ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] ?? requested;
|
||||
const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
|
||||
? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES]
|
||||
: requested;
|
||||
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
|
||||
}
|
||||
|
||||
@@ -121,7 +123,9 @@ 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 ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass] ?? NO_PROTECTED_AUTHORITY;
|
||||
return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
|
||||
? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
|
||||
: NO_PROTECTED_AUTHORITY;
|
||||
}
|
||||
|
||||
/** One discovered persona file (a single role contract on disk). */
|
||||
@@ -286,15 +290,6 @@ 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' &&
|
||||
@@ -314,10 +309,9 @@ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: stri
|
||||
const { classes, byClass } = acc;
|
||||
if (entry === 'LIBRARY.md') return;
|
||||
|
||||
// A successfully read role file is addressable by filename, including
|
||||
// marker-less compatibility contracts such as planner.md.
|
||||
// An explicit first marker owns identity. Filename identity is a fallback only
|
||||
// for markerless 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;
|
||||
@@ -330,8 +324,9 @@ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: stri
|
||||
markerDefined: true,
|
||||
...(domain ? { domain } : {}),
|
||||
});
|
||||
} else if (!byClass.has(stem)) {
|
||||
byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||
} else {
|
||||
classes.add(stem);
|
||||
if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +381,10 @@ 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,
|
||||
@@ -405,7 +404,7 @@ async function readPersonaFromLayer(
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
@@ -442,6 +441,10 @@ 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,
|
||||
@@ -461,7 +464,7 @@ function readPersonaFromLayerSync(
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (pf.markerDefined && currentMarker?.[1] !== canonicalClass) return null;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
@@ -512,10 +515,22 @@ export async function resolvePersonaFrom(
|
||||
|
||||
const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
|
||||
if (override) return override;
|
||||
// An explicit canonical override that cannot be resolved/read must shadow the
|
||||
// baseline fail-closed; only an absent override may fall back.
|
||||
// An observed explicit override that cannot resolve/read shadows the baseline.
|
||||
if (overrideShadowsBaseline(over, klass)) return null;
|
||||
if (over.scanState === 'missing' && !(await missingScanStillValid(overrideDir))) 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;
|
||||
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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/);
|
||||
});
|
||||
});
|
||||
121
packages/mosaic/src/fleet/example-profile-dispositions.ts
Normal file
121
packages/mosaic/src/fleet/example-profile-dispositions.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user