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
17 changes: 11 additions & 6 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ All packages (`@flyingrobots/bijou`, `@flyingrobots/bijou-node`, `@flyingrobots/

## [Unreleased]

### Added

- **VISOR artifact bundle proof** — `@flyingrobots/bijou` now exports
`createVisorArtifactBundleFromGraphql()`, `createVisorArtifactBundle()`, and
`visor-artifact-bundle/1` types for #458 / [`DX-049`](design/DX-049-visor-artifact-bundle-proof.md).
The builder wraps the checked-in DOGFOOD GraphQL fixture path,
`bijou-block/1`, `ui-scene-ir/1`, `graphql-bijou-block-debug/1`, replay
metadata, visual scene facts, raw and semantic source hashes, and stable
receipts while leaving #459 packed-cell validation and external
renderer/debugger work out of scope.

### Changed

- **VISOR artifact bundle shaping** —
[`DX-049`](design/DX-049-visor-artifact-bundle-proof.md) scopes #458 around a
deterministic `visor-artifact-bundle/1` that wraps the existing GraphQL
fixture path, `bijou-block/1`, `ui-scene-ir/1`, replay metadata, visual scene
facts, and stable hashes while leaving #459 packed-cell validation and
external renderer/debugger work out of scope.
- **Future release milestone triage** — The GitHub backlog is now assigned to
explicit `v8.0.0`, `v8.1.0`, `v8.2.0`, `v9.0.0`, and `v10.0.0` horizons;
`Beyond` and open unmilestoned issue queues are empty; and
Expand Down
36 changes: 21 additions & 15 deletions docs/design/DX-049-visor-artifact-bundle-proof.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,14 @@ visor-artifact-bundle/1
receipts[]
```

`bundleHash` is computed from the canonical bundle payload with the hash field
itself omitted. Hashing must use the same deterministic JSON rules already used
by the GraphQL block and scene IR proof path.
`sourceHash` records the raw source text hash. `normalizedSourceHash` records
the semantic source hash inherited from the compiled `bijou-block/1` artifact.
`bundleHash` is computed from the canonical semantic bundle payload with raw
`source.text`, raw `sourceHash` copies, and `bundleHash` itself omitted. That
lets VISOR inspect exact source text and raw source identity while preserving a
stable semantic bundle hash across whitespace-only SDL edits. Hashing must use
the same deterministic JSON rules already used by the GraphQL block and scene
IR proof path.

## Replay Metadata Contract

Expand Down Expand Up @@ -298,26 +303,27 @@ An agent should be able to answer, from the bundle alone:

## Validation Plan

Current design PR validation:
Current implementation validation:

```bash
npx vitest run --config vitest.config.ts tests/cycles/WF-130/roadmap-goalpost-policy.part01.test.ts tests/cycles/WF-130/roadmap-goalpost-policy.part02.test.ts tests/cycles/WF-130/roadmap-goalpost-policy.part04.test.ts
npx vitest run --config vitest.config.ts tests/cycles/DX-049
npx vitest run --config vitest.config.ts \
tests/cycles/WF-130/roadmap-goalpost-policy.part01.test.ts \
tests/cycles/WF-130/roadmap-goalpost-policy.part02.test.ts \
tests/cycles/WF-130/roadmap-goalpost-policy.part04.test.ts
npm run docs:inventory
npm run typecheck:test
npm run code-dojo:changed
npm run lint
git diff --check
```

Future implementation validation after the RED-test slice creates
`tests/cycles/DX-049`:

```bash
npx vitest run --config vitest.config.ts tests/cycles/DX-049
```

## Closeout Notes

Open. This design starts #458. Keep `needs-design` on the issue until the
design PR lands; keep `work-in-progress` on the issue while the branch and PR
carry the cycle.
Implementation in progress. The design PR has landed, so #458 no longer needs
`needs-design`. The implementation branch now carries focused
`tests/cycles/DX-049` coverage for the public bundle builder, exported bundle
type, raw-versus-semantic source hashes, replay metadata, visual scene facts,
absolute source-name rejection, malformed source rejection, duplicate visual
node rejection, and hash-mismatch rejection. Keep `work-in-progress` on the
issue while the branch and PR carry the implementation cycle.
141 changes: 141 additions & 0 deletions packages/bijou/src/core/visor-artifact-bundle.part01.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type {
UiNodeKind,
UiSceneIr,
UiSceneLowerMode,
UiSceneLoweringOptions,
UiTargetProfile,
UiTextRef,
} from './ui-scene-ir.js';
import type {
BijouBlockArtifact,
GraphqlBijouBlockDebugSummary,
} from './graphql-bijou-block.part01.js';

export const VISOR_ARTIFACT_BUNDLE_VERSION = 'visor-artifact-bundle/1' as const;
export const VISOR_REPLAY_METADATA_VERSION = 'visor-replay-metadata/1' as const;
export const VISOR_VISUAL_SCENE_FACTS_VERSION = 'visor-visual-scene-facts/1' as const;

export type VisorArtifactBundleVersion = typeof VISOR_ARTIFACT_BUNDLE_VERSION;
export type VisorReplayMetadataVersion = typeof VISOR_REPLAY_METADATA_VERSION;
export type VisorVisualSceneFactsVersion = typeof VISOR_VISUAL_SCENE_FACTS_VERSION;

export interface CreateVisorArtifactBundleFromGraphqlOptions extends UiSceneLoweringOptions {
readonly fixtureId: string;
readonly sourceName: string;
readonly scenarioId?: string;
readonly deterministicSeed?: string;
}

export interface CreateVisorArtifactBundleInput extends CreateVisorArtifactBundleFromGraphqlOptions {
readonly sourceText: string;
readonly bijouBlock: BijouBlockArtifact;
readonly uiScene: UiSceneIr;
readonly debugSummary: GraphqlBijouBlockDebugSummary;
}

export interface VisorArtifactBundleFixture {
readonly id: string;
readonly sourceName: string;
readonly sourceHash: string;
readonly normalizedSourceHash: string;
}

export interface VisorArtifactBundleSource {
readonly language: 'graphql';
readonly text: string;
}

export interface VisorArtifactBundleArtifacts {
readonly bijouBlock: BijouBlockArtifact;
readonly uiScene: UiSceneIr;
readonly debugSummary: GraphqlBijouBlockDebugSummary;
}

export interface VisorReplayStep {
readonly id: string;
readonly label: string;
readonly inputHashes: readonly string[];
readonly outputHashes: readonly string[];
}

export interface VisorReplayMetadata {
readonly replayVersion: VisorReplayMetadataVersion;
readonly scenarioId: string;
readonly fixtureId: string;
readonly deterministicSeed: string;
readonly consumedHashes: {
readonly sourceHash: string;
readonly normalizedSourceHash: string;
readonly artifactHash: string;
readonly sceneHash: string;
readonly debugSummaryHash: string;
readonly visualFactsHash: string;
};
readonly steps: readonly VisorReplayStep[];
}

export interface VisorVisualNodeFact {
readonly nodeId: string;
readonly kind: UiNodeKind;
readonly role?: string;
readonly component?: string;
readonly parentId?: string;
readonly childNodeIds: readonly string[];
readonly text?: UiTextRef;
readonly i18nKeys: readonly string[];
readonly tokenRefs: readonly string[];
readonly actionIds: readonly string[];
readonly bindingIds: readonly string[];
readonly sourceRefs: readonly string[];
}

export interface VisorVisualLowerModeFact {
readonly mode: UiSceneLowerMode;
readonly surfaceHash: string;
readonly rowsHash: string;
}

export interface VisorVisualSceneFacts {
readonly visualFactsVersion: VisorVisualSceneFactsVersion;
readonly sceneId: string;
readonly rootNodeId: string;
readonly targetProfiles: readonly UiTargetProfile[];
readonly nodeFacts: readonly VisorVisualNodeFact[];
readonly lowerModes: readonly VisorVisualLowerModeFact[];
}

export interface VisorArtifactBundleHashes {
readonly sourceHash: string;
readonly normalizedSourceHash: string;
readonly artifactHash: string;
readonly sceneHash: string;
readonly debugSummaryHash: string;
readonly replayHash: string;
readonly visualFactsHash: string;
readonly bundleHash: string;
}

export interface VisorArtifactBundleReceipt {
readonly subject: string;
readonly version: string;
readonly hash: string;
}

export interface VisorArtifactBundle {
readonly bundleVersion: VisorArtifactBundleVersion;
readonly fixture: VisorArtifactBundleFixture;
readonly source: VisorArtifactBundleSource;
readonly artifacts: VisorArtifactBundleArtifacts;
readonly replay: VisorReplayMetadata;
readonly visual: VisorVisualSceneFacts;
readonly hashes: VisorArtifactBundleHashes;
readonly receipts: readonly VisorArtifactBundleReceipt[];
}

export type VisorArtifactBundleHashBody = Omit<VisorArtifactBundleHashes, 'bundleHash'>;

export type VisorArtifactBundleWithoutHash = Omit<VisorArtifactBundle, 'hashes'> & {
readonly hashes: VisorArtifactBundleHashBody & {
readonly bundleHash?: string;
};
};
111 changes: 111 additions & 0 deletions packages/bijou/src/core/visor-artifact-bundle.part02.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { hashUiSceneValue } from './ui-scene-ir.js';
import { compileGraphqlBijouBlock } from './graphql-bijou-block.part02.js';
import { lowerBijouBlockToUiScene } from './graphql-bijou-block.part03.js';
import { createGraphqlBijouBlockDebugSummary } from './graphql-bijou-block.part04.js';
import { normalizeSourceName } from './graphql-bijou-block.part10.js';
import {
VISOR_ARTIFACT_BUNDLE_VERSION,
type CreateVisorArtifactBundleFromGraphqlOptions,
type CreateVisorArtifactBundleInput,
type VisorArtifactBundle,
type VisorArtifactBundleHashBody,
} from './visor-artifact-bundle.part01.js';
import {
createVisorReplayMetadata,
createVisorVisualSceneFacts,
} from './visor-artifact-bundle.part03.js';
import {
assertDebugSummaryMatchesFacts,
assertSceneMatchesArtifact,
createReceipts,
normalizeLogicalId,
semanticBundleHashPayload,
} from './visor-artifact-bundle.part04.js';

export function createVisorArtifactBundleFromGraphql(
source: string,
options: CreateVisorArtifactBundleFromGraphqlOptions,
): VisorArtifactBundle {
const sourceName = normalizeSourceName(options.sourceName);
const bijouBlock = compileGraphqlBijouBlock(source, { sourceName });
const uiScene = lowerBijouBlockToUiScene(bijouBlock);
const debugSummary = createGraphqlBijouBlockDebugSummary(bijouBlock, options);
return createVisorArtifactBundle({
...options,
sourceName,
sourceText: source,
bijouBlock,
uiScene,
debugSummary,
});
}

export function createVisorArtifactBundle(input: CreateVisorArtifactBundleInput): VisorArtifactBundle {
const fixtureId = normalizeLogicalId(input.fixtureId, 'fixtureId');
const scenarioId = normalizeLogicalId(input.scenarioId ?? `${fixtureId}:graphql-to-ui-scene`, 'scenarioId');
const deterministicSeed = input.deterministicSeed ?? 'no-randomness';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate deterministicSeed before replaying it

When callers provide deterministicSeed, this value is copied into replay metadata without the logical-id or host-local checks used for fixture/scenario ids. Passing a seed such as /tmp/run-123 or pid:... emits the absolute-path or process-local replay facts the bundle contract says must be rejected, so VISOR artifacts can become non-portable while still passing the builder.

Useful? React with 👍 / 👎.

const sourceName = normalizeSourceName(input.sourceName);

if (input.bijouBlock.sourceName !== sourceName) {
throw new Error('visor-artifact-bundle/1 sourceName does not match bijou-block/1.');
}

const visual = createVisorVisualSceneFacts(input.uiScene, input.debugSummary);
const visualFactsHash = hashUiSceneValue(visual);
assertSceneMatchesArtifact(input.bijouBlock, input.uiScene);
assertDebugSummaryMatchesFacts(input.bijouBlock, input.uiScene, input.debugSummary);

const sourceHash = hashUiSceneValue(input.sourceText);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject source text that does not compile to the artifact

For direct createVisorArtifactBundle() callers, sourceText is only hashed and embedded; it is never parsed or compared with the supplied bijouBlock. A caller can therefore pass malformed or unrelated SDL alongside a valid artifact/scene/debug summary and still get a bundle whose raw source does not produce the advertised normalized artifact, breaking the source-to-scene proof chain.

Useful? React with 👍 / 👎.

const normalizedSourceHash = input.bijouBlock.sourceHash;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the artifact source hash before trusting it

For direct createVisorArtifactBundle() callers, this trusts bijouBlock.sourceHash as the normalized source identity without checking that it matches the artifact body. If a prepared artifact has that field changed and the scene/debug summary are generated from that same artifact, the existing scene and debug hash checks still pass, but the bundle advertises a normalized source hash that no longer identifies the normalized bijou-block/1 facts.

Useful? React with 👍 / 👎.

const artifactHash = hashUiSceneValue(input.bijouBlock);
const sceneHash = hashUiSceneValue(input.uiScene);
const debugSummaryHash = input.debugSummary.summaryHash;
const replay = createVisorReplayMetadata({
fixtureId,
scenarioId,
deterministicSeed,
sourceHash,
normalizedSourceHash,
artifactHash,
sceneHash,
debugSummaryHash,
visualFactsHash,
});
const replayHash = hashUiSceneValue(replay);
const hashes: VisorArtifactBundleHashBody = {
sourceHash,
normalizedSourceHash,
artifactHash,
sceneHash,
debugSummaryHash,
replayHash,
visualFactsHash,
};
const bundleWithoutHash = {
bundleVersion: VISOR_ARTIFACT_BUNDLE_VERSION,
fixture: { id: fixtureId, sourceName, sourceHash, normalizedSourceHash },
source: { language: 'graphql' as const, text: input.sourceText },
artifacts: {
bijouBlock: input.bijouBlock,
uiScene: input.uiScene,
debugSummary: input.debugSummary,
},
replay,
visual,
hashes: { ...hashes, bundleHash: undefined },
receipts: createReceipts({
artifactHash,
sceneHash,
debugSummaryHash,
replayHash,
visualFactsHash,
}),
};
return {
...bundleWithoutHash,
hashes: {
...hashes,
bundleHash: hashUiSceneValue(semanticBundleHashPayload(bundleWithoutHash)),
},
};
}
Loading