Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions openspec/changes/support-non-asset-files/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@

## 8. Lockfile, Receipt, and Materialization — Research

- [ ] 8.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward
- [ ] 8.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering
- [ ] 8.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow
- [ ] 8.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior
- [x] 8.1 Explore: Trace lockfile loading/writing and every place resolved entries are inherited, minted, compared, or carried forward
- [x] 8.2 Explore: Trace receipt loading, bootstrapping, project isolation, drift removal, tri-write commit, and rollback ordering
- [x] 8.3 Explore: Trace materialization, skip-if-identical behavior, journaling, deletion, drift reporting, and archive-to-adapter data flow
- [x] 8.4 Propose: Define the migration and transaction approach for per-file integrity, untrusted receipt ownership, atomic skill bundles, normal legacy migration, and frozen legacy behavior

## 9. Lockfile, Receipt, and Materialization — Implementation

- [ ] 9.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior
- [ ] 9.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values
- [x] 9.1 Implement: Replace numeric-order lockfile handling with exact legacy-alpha-`1` and current-`0.2` loading, normal-mode migration, and frozen-mode no-rewrite behavior
- [x] 9.2 Implement: Derive sorted lockfile asset file records from the verified materialization subset and recomputed entry hashes rather than copying self-declared hash values
- [ ] 9.3 Implement: Enforce pre-materialization agreement among facet integrity, asset identities, complete owned path sets, recomputed entry hashes, and verified build-manifest hashes with path-specific result variants, running the adapter-compatibility preflight (positional `0.0` rejected by a `{0.1}` CLI) ahead of archive-version dispatch and per-file reconciliation
- [ ] 9.4 Implement: Introduce receipt `0.2` asset/file ownership, safe legacy refinement, project-isolated bootstrap, and containment validation that treats receipt data as untrusted
- [ ] 9.5 Implement: Commit lockfile, receipt, and adapter state transactionally and ensure frozen consistency gates complete before receipt-driven cleanup begins
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/commands/install/__tests__/install-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync,
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ADAPTER_API_VERSION } from '@agent-facets/adapter/api-version'
import { CURRENT_LOCKFILE_VERSION } from '@agent-facets/protocol'
import { captureStderr } from '../../../__tests__/helpers/capture-std.ts'
import { withTTY } from '../../../__tests__/helpers/with-tty.ts'
import { installCommand } from '../index.ts'
Expand Down Expand Up @@ -123,10 +124,19 @@ describe('facet install — CLI happy path', () => {
const lockPath = join(projectRoot, 'facets.lock')
expect(existsSync(lockPath)).toBe(true)
const lockfile = JSON.parse(readFileSync(lockPath, 'utf8'))
expect(lockfile.lockfileVersion).toBe(1)
// A fresh install writes the current (`0.2`) lockfile schema with
// per-materialized-file integrity records inside each asset.
expect(lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION)
expect(lockfile.facets['viper-plans']).toMatchObject({
version: '0.1.0',
assets: [{ scope: 'project', type: 'skill', name: 'planning' }],
assets: [
{
scope: 'project',
type: 'skill',
name: 'planning',
files: [{ path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }],
},
],
})
expect(lockfile.facets['viper-plans'].integrity).toMatch(/^sha256:/)

Expand Down
52 changes: 51 additions & 1 deletion packages/engine/src/__tests__/run-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { join } from 'node:path'
import type { Adapter } from '@agent-facets/adapter'
import { ADAPTER_API_VERSION, deleteAssetFile, installAssetFile, readAssetFile } from '@agent-facets/adapter'
import type { BuildManifest, Lockfile } from '@agent-facets/protocol'
import { computeContentHash } from '@agent-facets/protocol'
import { CURRENT_LOCKFILE_VERSION, CurrentLockfileSchema, computeContentHash } from '@agent-facets/protocol'
import { type } from 'arktype'
import { type CacheIdentity, cachePath, cachePutVerified, computeDirIntegrity } from '../cache/index.ts'
import { loadLockfile } from '../install/lockfile-io.ts'
import { runInstall } from '../install/run-install.ts'
import type { StageEvent } from '../install/types.ts'

Expand Down Expand Up @@ -294,6 +296,38 @@ describe('runInstall — local source success path', () => {
expect(result.summary.installed).toBe(1)
expect(result.lockfile.facets['viper-plans']?.version).toBe('0.1.0')
})

// 9.1/9.2: a fresh normal install records the current (`0.2`) lockfile
// with per-materialized-file integrity records derived from the verified
// build, not identity-only assets.
test('a fresh install writes a 0.2 lockfile with recomputed per-file records', async () => {
const local = buildLocalFixture('viper-plans')
const relPath = `./${local.split('/').pop()}`
writeFileSync(join(projectRoot, 'facets.json'), JSON.stringify({ facets: { 'viper-plans': relPath } }))

const result = await runInstall({ projectRoot, adapters: [buildFakeAdapter('test')] })
expect(result.ok).toBe(true)
if (!result.ok) expect.unreachable()

expect(result.lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION)

// The written lockfile round-trips: reloading it under exact 0.2 dispatch
// succeeds (an identity-only 0.2 entry would fail the CurrentLockfile
// schema on reload) and reports the current version.
const reloaded = loadLockfile(projectRoot)
if (!reloaded.ok) expect.unreachable()
expect(reloaded.version).toBe(CURRENT_LOCKFILE_VERSION)

// Validate the written bytes against the current schema and inspect the
// per-file records off the validated (current) shape.
const written = CurrentLockfileSchema(JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')))
if (written instanceof type.errors) expect.unreachable()
const asset = written.facets['viper-plans']?.assets.find((a) => a.type === 'skill' && a.name === 'planning')
if (asset === undefined) expect.unreachable()
expect(asset.files).toEqual([
{ path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) },
])
})
})

describe('runInstall — registry source surfaces REGISTRY_ERROR on resolution failure', () => {
Expand Down Expand Up @@ -887,6 +921,22 @@ describe('runInstall — git cache hit short-circuits clone', () => {
url: `https://github.com/example/${facetName}.git`,
commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
})

// 9.1/9.2 migration: the seeded lockfile was legacy-alpha `1` with
// identity-only assets. A normal (non-frozen) install migrates it to the
// current `0.2` schema, re-deriving per-file records from the verified
// slot while keeping the locked identity untouched.
expect(result.lockfile.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION)
const written = CurrentLockfileSchema(JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8')))
if (written instanceof type.errors) expect.unreachable()
expect(written.facets[facetName]?.assets).toEqual([
{
scope: 'project',
type: 'skill',
name: 'planning',
files: [{ path: 'skills/planning/SKILL.md', integrity: expect.stringMatching(/^sha256:[a-f0-9]{64}$/) }],
},
])
})

test('returns CACHE_INTEGRITY_MISMATCH when sidecar disagrees with lockfile', async () => {
Expand Down
109 changes: 89 additions & 20 deletions packages/engine/src/install/__tests__/lockfile-io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { CURRENT_LOCKFILE_VERSION, LEGACY_LOCKFILE_VERSION } from '@agent-facets/protocol'
import { FACETS_LOCK_FILE, loadLockfile, writeLockfile } from '../lockfile-io.ts'

let projectRoot: string
Expand All @@ -15,20 +16,21 @@ afterEach(() => {
})

describe('loadLockfile — empty/missing', () => {
test('missing file returns empty lockfile with existed=false', () => {
test('missing file returns a current (0.2) empty lockfile with existed=false', () => {
const result = loadLockfile(projectRoot)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.existed).toBe(false)
expect(result.data.facets).toEqual({})
}
if (!result.ok) expect.unreachable()
expect(result.existed).toBe(false)
expect(result.data.facets).toEqual({})
expect(result.data.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION)
expect(result.version).toBe(CURRENT_LOCKFILE_VERSION)
})
})

describe('loadLockfile — round-trip', () => {
test('writes and reads back an identical lockfile', () => {
test('writes and reads back an identical current (0.2) lockfile', () => {
const lockfile = {
lockfileVersion: 1 as const,
lockfileVersion: CURRENT_LOCKFILE_VERSION as typeof CURRENT_LOCKFILE_VERSION,
facets: {
'viper-plans': {
source: {
Expand All @@ -38,14 +40,47 @@ describe('loadLockfile — round-trip', () => {
},
version: '0.1.0',
integrity: 'sha256:deadbeef',
assets: [{ scope: 'project' as const, type: 'skill' as const, name: 'planning' }],
assets: [
{
scope: 'project' as const,
type: 'skill' as const,
name: 'planning',
files: [{ path: 'skills/planning/SKILL.md', integrity: `sha256:${'0'.repeat(64)}` }],
},
],
},
},
}
writeLockfile(projectRoot, lockfile)
const loaded = loadLockfile(projectRoot)
expect(loaded.ok).toBe(true)
if (loaded.ok) expect(loaded.data).toEqual(lockfile)
if (!loaded.ok) expect.unreachable()
expect(loaded.data).toEqual(lockfile)
expect(loaded.version).toBe(CURRENT_LOCKFILE_VERSION)
})

test('loads a legacy-alpha (1) lockfile under the legacy schema during the compatibility window', () => {
const legacy = {
lockfileVersion: LEGACY_LOCKFILE_VERSION as typeof LEGACY_LOCKFILE_VERSION,
facets: {
'viper-plans': {
source: {
kind: 'git' as const,
url: 'github:agent-facets/viper-plans#main',
commit: 'abc123def0123456789abc123def0123456789ab',
},
version: '0.1.0',
integrity: 'sha256:deadbeef',
assets: [{ scope: 'project' as const, type: 'skill' as const, name: 'planning' }],
},
},
}
writeLockfile(projectRoot, legacy)
const loaded = loadLockfile(projectRoot)
expect(loaded.ok).toBe(true)
if (!loaded.ok) expect.unreachable()
expect(loaded.data).toEqual(legacy)
expect(loaded.version).toBe(LEGACY_LOCKFILE_VERSION)
})
})

Expand All @@ -68,25 +103,59 @@ describe('loadLockfile — error paths', () => {
})
})

// F9 — forward-compat guard. A lockfile from a future CLI must produce a
// clear "upgrade the CLI" message, not a generic arktype mismatch.
describe('loadLockfile — F9 forward-compat guard', () => {
test('lockfileVersion > LOCKFILE_VERSION fails with an actionable error', () => {
// Exact version dispatch (design D10). An unsupported/unknown version must
// produce an actionable "upgrade the CLI" message, not a generic arktype
// mismatch — and dispatch is by exact equality, never numeric ordering.
describe('loadLockfile — exact version dispatch', () => {
test('an unsupported lockfileVersion fails with an actionable error', () => {
writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 99, facets: {} }))
const result = loadLockfile(projectRoot)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.error).toContain('newer facet CLI')
expect(result.error).toContain('lockfileVersion 99')
expect(result.error).toContain('Upgrade the CLI')
}
if (result.ok) expect.unreachable()
expect(result.error).toContain('unsupported lockfileVersion')
expect(result.error).toContain('99')
expect(result.error).toContain('Upgrade the CLI')
})

test('lockfileVersion equal to LOCKFILE_VERSION loads normally', () => {
test('legacy-alpha version 1 loads under the legacy schema', () => {
writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 1, facets: {} }))
const result = loadLockfile(projectRoot)
expect(result.ok).toBe(true)
if (result.ok) expect(result.existed).toBe(true)
if (!result.ok) expect.unreachable()
expect(result.existed).toBe(true)
expect(result.version).toBe(LEGACY_LOCKFILE_VERSION)
})

test('current version 0.2 loads under the current schema', () => {
writeFileSync(join(projectRoot, FACETS_LOCK_FILE), JSON.stringify({ lockfileVersion: 0.2, facets: {} }))
const result = loadLockfile(projectRoot)
expect(result.ok).toBe(true)
if (!result.ok) expect.unreachable()
expect(result.existed).toBe(true)
expect(result.version).toBe(CURRENT_LOCKFILE_VERSION)
})

test('a malformed 0.2 lockfile is not reinterpreted as legacy 1', () => {
// `files` is required on 0.2 asset entries; omitting it is a 0.2 schema
// violation, never a fallback to the legacy identity-only shape.
writeFileSync(
join(projectRoot, FACETS_LOCK_FILE),
JSON.stringify({
lockfileVersion: 0.2,
facets: {
x: {
source: { kind: 'registry', registry: 'https://example.com' },
version: '1.0.0',
integrity: 'sha256:deadbeef',
assets: [{ scope: 'project', type: 'skill', name: 'planning' }],
},
},
}),
)
const result = loadLockfile(projectRoot)
expect(result.ok).toBe(false)
if (result.ok) expect.unreachable()
expect(result.error).toContain('lockfileVersion 0.2')
})
})

Expand Down
7 changes: 5 additions & 2 deletions packages/engine/src/install/__tests__/run-remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Adapter } from '@agent-facets/adapter'
import { ADAPTER_API_VERSION } from '@agent-facets/adapter/api-version'
import { CURRENT_LOCKFILE_VERSION } from '@agent-facets/protocol'

/**
* Tests for the `facet remove` orchestrator (`runRemove`).
Expand Down Expand Up @@ -279,9 +280,11 @@ describe('runRemove — last facet', () => {

expect(Object.keys(readFacets())).toHaveLength(0)
expect(Object.keys(readLockfileFacets())).toHaveLength(0)
// Lockfile is still structurally valid (declares a version).
// Lockfile is still structurally valid: a normal install writes the
// current (`0.2`) schema. Version dispatch is exact, not ordered, so
// `0.2` is the current version even though it is numerically < 1.
const lock = JSON.parse(readFileSync(join(projectRoot, 'facets.lock'), 'utf8'))
expect(lock.lockfileVersion).toBeGreaterThanOrEqual(1)
expect(lock.lockfileVersion).toBe(CURRENT_LOCKFILE_VERSION)
})
})

Expand Down
15 changes: 11 additions & 4 deletions packages/engine/src/install/commit/finalize-facet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LockfileSource, ResolvedFacetManifest } from '@agent-facets/protocol'
import type { FacetManifest, LockfileSource, ResolvedFacetManifest } from '@agent-facets/protocol'
import { loadManifest, resolvePrompts } from '../../loaders/facet.ts'
import { getRegistryBaseUrl } from '../../registry/index.ts'
import type { Source } from '../../sources/facet/types.ts'
Expand All @@ -7,10 +7,17 @@ import type { RunInstallFailure, StageEvent } from '../types.ts'
/**
* Result of loading and validating a facet's content from a resolved
* source directory. On success carries the prompt-resolved manifest
* (what `materialize` consumes) and the facet's server declarations.
* (what `materialize` consumes), the validated raw manifest (what the
* verified-asset-plan derivation consumes for archive classification), and
* the facet's server declarations.
*/
export type LoadFacetContentResult =
| { ok: true; resolved: ResolvedFacetManifest; serversDeclared: ReadonlyArray<string> }
| {
ok: true
manifest: FacetManifest
resolved: ResolvedFacetManifest
serversDeclared: ReadonlyArray<string>
}
| { ok: false; failure: RunInstallFailure }

/**
Expand Down Expand Up @@ -63,7 +70,7 @@ export async function loadFacetContent(
}
}

return { ok: true, resolved: resolved.data, serversDeclared }
return { ok: true, manifest: rawManifest.data, resolved: resolved.data, serversDeclared }
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/engine/src/install/commit/resolve-facet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export async function resolveFacet(args: ResolveFacetArgs): Promise<ResolveFacet

switch (source.kind) {
case 'registry':
return resolveRegistryFacet({ facetName, source, effectiveLocked, onStage, onLog })
return resolveRegistryFacet({ facetName, source, effectiveLocked, frozenLockfile, onStage, onLog })
case 'git':
return resolveGitFacet({ facetName, source, adapters, effectiveLocked, onStage, onLog })
return resolveGitFacet({ facetName, source, adapters, effectiveLocked, frozenLockfile, onStage, onLog })
case 'local':
return resolveLocalFacet({
facetName,
Expand Down
Loading