From 2fad685e244d387de7751fc51b228b345580ba58 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:18:53 -0700 Subject: [PATCH 01/11] Feat: Route state reader properties through projections --- docs/BEARING.md | 2 +- .../v18-state-reader-property-projection.md | 2 +- src/domain/services/state/StateReader.ts | 16 +- .../services/state/StateReaderContext.ts | 273 ++++++------------ .../StateReaderPropertyProjection.test.ts | 116 ++++++++ 5 files changed, 212 insertions(+), 197 deletions(-) create mode 100644 test/unit/domain/services/StateReaderPropertyProjection.test.ts diff --git a/docs/BEARING.md b/docs/BEARING.md index f97ec274..5bb0b44f 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -191,7 +191,7 @@ and concrete checks live in `docs/invariants/`. [0177](design/0177-v18-edge-property-projection/v18-edge-property-projection.md). - [x] 30. Route query property reads through projection: [0178](design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md). -- [ ] 31. Route state-reader property views through projection: +- [x] 31. Route state-reader property views through projection: [0179](design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md). - [ ] 32. Add property write intent nouns: [0180](design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md). diff --git a/docs/design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md b/docs/design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md index f7761386..fec034b5 100644 --- a/docs/design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md +++ b/docs/design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md @@ -1,7 +1,7 @@ --- cycle: 0179 task_id: V18_state_reader_property_projection -status: Planned +status: Complete sponsors: human: James agent: Codex diff --git a/src/domain/services/state/StateReader.ts b/src/domain/services/state/StateReader.ts index b8083ee0..771e9157 100644 --- a/src/domain/services/state/StateReader.ts +++ b/src/domain/services/state/StateReader.ts @@ -12,12 +12,16 @@ import { cloneNeighbors, createEdgeContentMetaIndex, createEdgePropIndex, + createEdgePropertyRecords, createNeighborIndex, createNodeContentMetaIndex, createNodePropIndex, + createNodePropertyRecords, + createProjectionProps, createVisibleEdges, edgeKeyFromRef, - populateVisibleProps, + populateVisibleEdgeProps, + populateVisibleNodeProps, } from './StateReaderContext.ts'; import type { VisibleStateReader } from '../../types/VisibleStateReader.ts'; import type { PropValue } from '../../types/PropValue.ts'; @@ -186,13 +190,19 @@ function buildReaderApi(context: StateReaderContext): VisibleStateReader { /** Builds the full reader context from materialized state, including all indexes. */ function buildReaderContext(state: WarpState): StateReaderContext { - const projection = projectState(state); + const baseProjection = projectState(state); + const projection = { + nodes: baseProjection.nodes, + edges: baseProjection.edges, + props: createProjectionProps(state), + }; const visibleNodeIds = new Set(projection.nodes); const nodePropsById = createNodePropIndex(projection.nodes); const edgePropsByKey = createEdgePropIndex(projection.edges); const { outgoingByNode, incomingByNode } = createNeighborIndex(projection.nodes, projection.edges); - populateVisibleProps(state, { visibleNodeIds, nodePropsById, edgePropsByKey }); + populateVisibleNodeProps(createNodePropertyRecords(state), nodePropsById); + populateVisibleEdgeProps(createEdgePropertyRecords(state), edgePropsByKey); return { projection, diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index 648da7b6..c53d7f91 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -1,19 +1,15 @@ -import { compareEventIds, type EventId } from '../../utils/EventId.ts'; -import { type LWWRegister } from '../../crdt/LWW.ts'; -import { - CONTENT_MIME_PROPERTY_KEY, - CONTENT_PROPERTY_KEY, - CONTENT_SIZE_PROPERTY_KEY, - decodeEdgePropKey, - decodePropKey, - encodeEdgeKey, - encodeEdgePropKey, - encodePropKey, - isEdgePropKey, -} from '../KeyCodec.ts'; +import EdgeRecord from '../../graph/EdgeRecord.ts'; +import NodeRecord from '../../graph/NodeRecord.ts'; +import { encodeEdgeKey } from '../KeyCodec.ts'; import { createSnapshotPropValue } from '../ImmutableSnapshot.ts'; +import ContentAttachmentProjection from '../ContentAttachmentProjection.ts'; +import EdgePropertyProjection from '../EdgePropertyProjection.ts'; +import NodePropertyProjection from '../NodePropertyProjection.ts'; +import type ContentAttachmentRecord from '../../graph/ContentAttachmentRecord.ts'; import type { PropValue } from '../../types/PropValue.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; +import type VisibleEdgePropertyRecord from '../../graph/VisibleEdgePropertyRecord.ts'; +import type VisibleNodePropertyRecord from '../../graph/VisibleNodePropertyRecord.ts'; import type WarpState from './WarpState.ts'; // ── Public types ──────────────────────────────────────────────────────────── @@ -48,44 +44,6 @@ export type StateReaderContext = { edgeContentMetaByKey: Map; }; -// ── Attachment lineage helpers ─────────────────────────────────────────────── - -/** - * Returns true when two registers were written in the same patch lineage. - * - * Content metadata is stored in sibling properties, so read-side helpers only - * treat `_content.mime` / `_content.size` as current when they were written in - * the same patch as the live `_content` reference. - */ -function isSameAttachmentLineage( - contentEventId: EventId | undefined, - candidateEventId: EventId | undefined, -): boolean { - return Boolean( - contentEventId - && candidateEventId - && contentEventId.lamport === candidateEventId.lamport - && contentEventId.writerId === candidateEventId.writerId - && contentEventId.patchSha === candidateEventId.patchSha, - ); -} - -/** - * Filters an edge-property register against the edge birth event. - */ -function visibleEdgeRegister( - register: LWWRegister | undefined, - birthEvent: EventId | undefined, -): LWWRegister | null { - if (!register) { - return null; - } - if (birthEvent && compareEventIds(register.eventId, birthEvent) < 0) { - return null; - } - return register; -} - // ── Edge key helper ────────────────────────────────────────────────────────── /** Encodes a visible edge reference into a composite key string. */ @@ -93,97 +51,6 @@ export function edgeKeyFromRef(edge: VisibleEdgeRef): string { return encodeEdgeKey(edge.from, edge.to, edge.label); } -// ── Content register helpers ───────────────────────────────────────────────── - -type ContentRegisters = { - contentRegister: LWWRegister; - mimeRegister: LWWRegister | null; - sizeRegister: LWWRegister | null; -}; - -/** Looks up the current node attachment registers directly from materialized state. */ -export function getNodeContentRegisters(state: WarpState, nodeId: string): ContentRegisters | null { - if (!state.nodeAlive.contains(nodeId)) { - return null; - } - const contentRegister = state.prop.get(encodePropKey(nodeId, CONTENT_PROPERTY_KEY)); - if (!contentRegister || typeof contentRegister.value !== 'string') { - return null; - } - return { - contentRegister: contentRegister as LWWRegister, - mimeRegister: state.prop.get(encodePropKey(nodeId, CONTENT_MIME_PROPERTY_KEY)) ?? null, - sizeRegister: state.prop.get(encodePropKey(nodeId, CONTENT_SIZE_PROPERTY_KEY)) ?? null, - }; -} - -/** Looks up the current edge attachment registers directly from materialized state. */ -export function getEdgeContentRegisters(state: WarpState, edge: VisibleEdgeRef): ContentRegisters | null { - const edgeKey = edgeKeyFromRef(edge); - if (!state.edgeAlive.contains(edgeKey)) { - return null; - } - if (!state.nodeAlive.contains(edge.from) || !state.nodeAlive.contains(edge.to)) { - return null; - } - - const birthEvent = state.edgeBirthEvent?.get(edgeKey); - - function getRegister(propKey: string): LWWRegister | null { - return visibleEdgeRegister( - state.prop.get(encodeEdgePropKey(edge.from, edge.to, edge.label, propKey)), - birthEvent, - ); - } - - const contentRegister = getRegister(CONTENT_PROPERTY_KEY); - if (!contentRegister || typeof contentRegister.value !== 'string') { - return null; - } - - return { - contentRegister: contentRegister as LWWRegister, - mimeRegister: getRegister(CONTENT_MIME_PROPERTY_KEY), - sizeRegister: getRegister(CONTENT_SIZE_PROPERTY_KEY), - }; -} - -// ── Metadata extraction helpers ────────────────────────────────────────────── - -/** Reads the value of an attachment sibling if it shares the same lineage. */ -function readAttachmentSiblingValue( - contentEventId: EventId | undefined, - register: LWWRegister | null | undefined, -): PropValue | null { - if (!isSameAttachmentLineage(contentEventId, register?.eventId)) { - return null; - } - return register?.value ?? null; -} - -/** Coerces a value to a MIME string or returns null. */ -function coerceMime(value: PropValue | null): string | null { - return typeof value === 'string' ? value : null; -} - -/** Coerces a value to a non-negative integer size or returns null. */ -function coerceSize(value: PropValue | null): number | null { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null; -} - -/** Extracts structured content metadata from attachment sibling properties. */ -export function extractContentMeta( - contentRegister: LWWRegister, - mimeRegister: LWWRegister | null, - sizeRegister: LWWRegister | null, -): ContentMeta { - return { - oid: contentRegister.value, - mime: coerceMime(readAttachmentSiblingValue(contentRegister.eventId, mimeRegister)), - size: coerceSize(readAttachmentSiblingValue(contentRegister.eventId, sizeRegister)), - }; -} - // ── Cloning helpers ────────────────────────────────────────────────────────── /** Shallow-clones a property bag. */ @@ -244,40 +111,38 @@ export function createNeighborIndex( return { outgoingByNode, incomingByNode }; } -/** Populates node and edge property indexes from materialized state registers. */ -export function populateVisibleProps( - state: WarpState, - indexes: { - visibleNodeIds: Set; - nodePropsById: Map; - edgePropsByKey: Map; - }, +/** Builds projection-backed public node property rows. */ +export function createProjectionProps(state: WarpState): VisibleProjectionProp[] { + return NodePropertyProjection.fromState(state).map((record) => ({ + node: record.owner.id.toString(), + key: record.key.toString(), + value: record.value.toPropValue(), + })); +} + +/** Populates node property indexes from projection records. */ +export function populateVisibleNodeProps( + records: readonly VisibleNodePropertyRecord[], + nodePropsById: Map, ): void { - const { visibleNodeIds, nodePropsById, edgePropsByKey } = indexes; - for (const [propKey, register] of state.prop) { - if (!isEdgePropKey(propKey)) { - const { nodeId, propKey: key } = decodePropKey(propKey); - if (visibleNodeIds.has(nodeId)) { - nodePropsById.get(nodeId)![key] = createSnapshotPropValue(register.value); - } - continue; + for (const record of records) { + const props = nodePropsById.get(record.owner.id.toString()); + if (props !== undefined) { + props[record.key.toString()] = createSnapshotPropValue(record.value.toPropValue()); } + } +} - const decoded = decodeEdgePropKey(propKey); - const edge = { from: decoded.from, to: decoded.to, label: decoded.label }; - const edgeKey = edgeKeyFromRef(edge); - const props = edgePropsByKey.get(edgeKey); - const birthEvent = state.edgeBirthEvent?.get(edgeKey); - if ( - props === undefined - || (birthEvent !== undefined - && register.eventId !== null - && register.eventId !== undefined - && compareEventIds(register.eventId, birthEvent) < 0) - ) { - continue; +/** Populates edge property indexes from projection records. */ +export function populateVisibleEdgeProps( + records: readonly VisibleEdgePropertyRecord[], + edgePropsByKey: Map, +): void { + for (const record of records) { + const props = edgePropsByKey.get(edgeKeyFromRecord(record.owner)); + if (props !== undefined) { + props[record.key.toString()] = createSnapshotPropValue(record.value.toPropValue()); } - props[decoded.propKey] = createSnapshotPropValue(register.value); } } @@ -297,17 +162,15 @@ export function createNodeContentMetaIndex( state: WarpState, nodeIds: string[], ): Map { - return new Map( - nodeIds.map((nodeId) => { - const registers = getNodeContentRegisters(state, nodeId); - return [ - nodeId, - registers - ? extractContentMeta(registers.contentRegister, registers.mimeRegister, registers.sizeRegister) - : null, - ]; - }), + const byNodeId: Map = new Map( + nodeIds.map((nodeId) => [nodeId, null]), ); + for (const record of ContentAttachmentProjection.fromState(state)) { + if (record.owner instanceof NodeRecord) { + byNodeId.set(record.owner.id.toString(), contentMetaFromRecord(record)); + } + } + return byNodeId; } /** Builds a content metadata index for all visible edges. */ @@ -315,15 +178,41 @@ export function createEdgeContentMetaIndex( state: WarpState, edges: VisibleEdgeRef[], ): Map { - return new Map( - edges.map((edge) => { - const registers = getEdgeContentRegisters(state, edge); - return [ - edgeKeyFromRef(edge), - registers - ? extractContentMeta(registers.contentRegister, registers.mimeRegister, registers.sizeRegister) - : null, - ]; - }), + const byEdgeKey: Map = new Map( + edges.map((edge) => [edgeKeyFromRef(edge), null]), ); + for (const record of ContentAttachmentProjection.fromState(state)) { + if (record.owner instanceof EdgeRecord) { + byEdgeKey.set(edgeKeyFromRecord(record.owner), contentMetaFromRecord(record)); + } + } + return byEdgeKey; +} + +/** Returns projection records for visible node properties. */ +export function createNodePropertyRecords(state: WarpState): readonly VisibleNodePropertyRecord[] { + return NodePropertyProjection.fromState(state); +} + +/** Returns projection records for visible edge properties. */ +export function createEdgePropertyRecords(state: WarpState): readonly VisibleEdgePropertyRecord[] { + return EdgePropertyProjection.fromState(state); +} + +/** Encodes an edge record into the state-reader edge key. */ +function edgeKeyFromRecord(record: EdgeRecord): string { + return edgeKeyFromRef({ + from: record.from.toString(), + to: record.to.toString(), + label: record.typeId.toString(), + }); +} + +/** Converts a typed content attachment record into public reader metadata. */ +function contentMetaFromRecord(record: ContentAttachmentRecord): ContentMeta { + return { + oid: record.payload.oid.toString(), + mime: record.payload.mime?.toString() ?? null, + size: record.payload.size?.toNumber() ?? null, + }; } diff --git a/test/unit/domain/services/StateReaderPropertyProjection.test.ts b/test/unit/domain/services/StateReaderPropertyProjection.test.ts new file mode 100644 index 00000000..0476066d --- /dev/null +++ b/test/unit/domain/services/StateReaderPropertyProjection.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; +import { + getEdgePropsImpl, + getNodePropsImpl, +} from '../../../../src/domain/services/controllers/QueryReads.ts'; +import type { QueryReadHost } from '../../../../src/domain/services/controllers/ReadGraphHost.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, + EDGE_PROP_PREFIX, + encodeEdgeKey, + encodeEdgePropKey, + encodePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; +import { createStateReader } from '../../../../src/domain/services/state/StateReader.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import type { PropValue } from '../../../../src/domain/types/PropValue.ts'; +import { EventId } from '../../../../src/domain/utils/EventId.ts'; + +const PATCH_SHA = 'c'.repeat(40); + +describe('StateReader property projection routing', () => { + it('reads node and edge property bags through projection records', async () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodePropKey('node:1', 'status'), register(4, 'ready')); + state.prop.set('node:1\0bad\0extra', register(5, 'ignored')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(6, 3)); + state.prop.set(`${EDGE_PROP_PREFIX}node:1\0node:2\0rel\0bad\0extra`, register(7, 'ignored')); + + const reader = createStateReader(state); + const host = hostForState(state); + + expect(reader.getNodeProps('node:1')).toEqual({ status: 'ready' }); + expect(reader.getEdgeProps('node:1', 'node:2', 'rel')).toEqual({ weight: 3 }); + expect(reader.getEdges()).toEqual([ + { from: 'node:1', to: 'node:2', label: 'rel', props: { weight: 3 } }, + ]); + expect(reader.project().props).toEqual([ + { node: 'node:1', key: 'status', value: 'ready' }, + ]); + await expect(getNodePropsImpl(host, 'node:1')).resolves.toEqual(reader.getNodeProps('node:1')); + await expect(getEdgePropsImpl(host, { + from: 'node:1', + to: 'node:2', + label: 'rel', + })).resolves.toEqual(reader.getEdgeProps('node:1', 'node:2', 'rel')); + }); + + it('reads content metadata through the content attachment projection', () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodePropKey('node:1', CONTENT_PROPERTY_KEY), register(4, 'node-oid')); + state.prop.set(encodePropKey('node:1', CONTENT_MIME_PROPERTY_KEY), register(5, 'ignored/old')); + state.prop.set(encodePropKey('node:1', CONTENT_SIZE_PROPERTY_KEY), register(4, 512)); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', CONTENT_PROPERTY_KEY), register(6, 'edge-oid')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', CONTENT_MIME_PROPERTY_KEY), register(6, 'text/plain')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', CONTENT_SIZE_PROPERTY_KEY), register(7, 999)); + + const reader = createStateReader(state); + + expect(reader.getNodeContentMeta('node:1')).toEqual({ + oid: 'node-oid', + mime: null, + size: 512, + }); + expect(reader.getEdgeContentMeta('node:1', 'node:2', 'rel')).toEqual({ + oid: 'edge-oid', + mime: 'text/plain', + size: null, + }); + }); +}); + +function hostForState(state: WarpState): QueryReadHost { + return { + _cachedState: state, + _autoMaterialize: true, + _propertyReader: null, + _logicalIndex: null, + _materializedGraph: null, + _ensureFreshState: async () => {}, + }; +} + +function addLiveNode(state: WarpState, nodeId: string, counter: number): void { + state.nodeAlive.add(nodeId, Dot.create('writer', counter)); +} + +function addLiveEdge( + state: WarpState, + from: string, + to: string, + label: string, + counter: number, +): void { + const edgeKey = encodeEdgeKey(from, to, label); + state.edgeAlive.add(edgeKey, Dot.create('writer', counter)); + state.edgeBirthEvent.set(edgeKey, event(counter)); +} + +function register(opIndex: number, value: PropValue): LWWRegister { + return LWWRegister.set(event(opIndex), value); +} + +function event(opIndex: number): EventId { + return new EventId(opIndex, 'writer', PATCH_SHA, 0); +} From 182cbc86c250ac2f3b5fe63638c33001ebbecbf6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:22:46 -0700 Subject: [PATCH 02/11] Feat: Add property write intent nouns --- docs/BEARING.md | 2 +- .../v18-property-write-intent-nouns.md | 2 +- src/domain/graph/EdgePropertyWriteIntent.ts | 101 ++++++++++++++++++ src/domain/graph/NodePropertyWriteIntent.ts | 90 ++++++++++++++++ src/domain/graph/publicGraphSubstrate.ts | 8 ++ .../domain/graph/PropertyWriteIntent.test.ts | 86 +++++++++++++++ 6 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 src/domain/graph/EdgePropertyWriteIntent.ts create mode 100644 src/domain/graph/NodePropertyWriteIntent.ts create mode 100644 test/unit/domain/graph/PropertyWriteIntent.test.ts diff --git a/docs/BEARING.md b/docs/BEARING.md index 5bb0b44f..5a98dff3 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -193,7 +193,7 @@ and concrete checks live in `docs/invariants/`. [0178](design/0178-v18-query-property-projection-reads/v18-query-property-projection-reads.md). - [x] 31. Route state-reader property views through projection: [0179](design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md). -- [ ] 32. Add property write intent nouns: +- [x] 32. Add property write intent nouns: [0180](design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md). - [ ] 33. Route PatchBuilder property writes through intent lowering: [0181](design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md). diff --git a/docs/design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md b/docs/design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md index 2989694f..c1065425 100644 --- a/docs/design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md +++ b/docs/design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md @@ -1,7 +1,7 @@ --- cycle: 0180 task_id: V18_property_write_intent_nouns -status: Planned +status: Complete sponsors: human: James agent: Codex diff --git a/src/domain/graph/EdgePropertyWriteIntent.ts b/src/domain/graph/EdgePropertyWriteIntent.ts new file mode 100644 index 00000000..0dd004bc --- /dev/null +++ b/src/domain/graph/EdgePropertyWriteIntent.ts @@ -0,0 +1,101 @@ +import EdgeRecord, { type LegacyEdgeFields } from './EdgeRecord.ts'; +import LegacyEdgePropertyKey from './LegacyEdgePropertyKey.ts'; +import LegacyPropertyValue from './LegacyPropertyValue.ts'; +import WarpError from '../errors/WarpError.ts'; +import type { PropValue } from '../types/PropValue.ts'; + +export type EdgePropertyWriteTarget = LegacyEdgeFields; + +export type EdgePropertyWriteIntentFields = { + readonly owner: EdgeRecord; + readonly key: LegacyEdgePropertyKey; + readonly value: LegacyPropertyValue; +}; + +export type LegacyEdgePropertyWriteFields = EdgePropertyWriteTarget & { + readonly key: string; + readonly value: PropValue; +}; + +/** Runtime-backed intent for writing one legacy-compatible edge property. */ +export default class EdgePropertyWriteIntent { + readonly owner: EdgeRecord; + readonly key: LegacyEdgePropertyKey; + readonly value: LegacyPropertyValue; + + constructor(fields: EdgePropertyWriteIntentFields) { + const checkedFields = requireFields(fields); + this.owner = requireOwner(checkedFields.owner); + this.key = requireKey(checkedFields.key); + this.value = requireValue(checkedFields.value); + Object.freeze(this); + } + + /** Builds a write intent from the current public edge-property API fields. */ + static fromLegacyProperty(fields: LegacyEdgePropertyWriteFields): EdgePropertyWriteIntent { + return new EdgePropertyWriteIntent({ + owner: EdgeRecord.fromLegacyEdge(fields), + key: new LegacyEdgePropertyKey(fields.key), + value: new LegacyPropertyValue(fields.value), + }); + } + + /** Returns the current legacy edge target. */ + edgeTarget(): EdgePropertyWriteTarget { + return { + from: this.owner.from.toString(), + to: this.owner.to.toString(), + label: this.owner.typeId.toString(), + }; + } + + /** Returns the current legacy property key target. */ + propertyKey(): string { + return this.key.toString(); + } + + /** Returns a defensive copy of the property value. */ + propertyValue(): PropValue { + return this.value.toPropValue(); + } +} + +/** Validates the write-intent constructor envelope. */ +function requireFields( + fields: EdgePropertyWriteIntentFields | null | undefined, +): EdgePropertyWriteIntentFields { + if (fields === null || fields === undefined) { + throw new WarpError('EdgePropertyWriteIntent fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed edge owner. */ +function requireOwner(owner: EdgeRecord): EdgeRecord { + if (!(owner instanceof EdgeRecord)) { + throw new WarpError('EdgePropertyWriteIntent owner must be an EdgeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires a runtime-backed edge property key. */ +function requireKey(key: LegacyEdgePropertyKey): LegacyEdgePropertyKey { + if (!(key instanceof LegacyEdgePropertyKey)) { + throw new WarpError( + 'EdgePropertyWriteIntent key must be a LegacyEdgePropertyKey', + 'E_VALIDATION', + ); + } + return key; +} + +/** Requires a runtime-backed property value. */ +function requireValue(value: LegacyPropertyValue): LegacyPropertyValue { + if (!(value instanceof LegacyPropertyValue)) { + throw new WarpError( + 'EdgePropertyWriteIntent value must be a LegacyPropertyValue', + 'E_VALIDATION', + ); + } + return value; +} diff --git a/src/domain/graph/NodePropertyWriteIntent.ts b/src/domain/graph/NodePropertyWriteIntent.ts new file mode 100644 index 00000000..cd387240 --- /dev/null +++ b/src/domain/graph/NodePropertyWriteIntent.ts @@ -0,0 +1,90 @@ +import LegacyNodePropertyKey from './LegacyNodePropertyKey.ts'; +import LegacyPropertyValue from './LegacyPropertyValue.ts'; +import NodeRecord from './NodeRecord.ts'; +import WarpError from '../errors/WarpError.ts'; +import type { PropValue } from '../types/PropValue.ts'; + +export type NodePropertyWriteIntentFields = { + readonly owner: NodeRecord; + readonly key: LegacyNodePropertyKey; + readonly value: LegacyPropertyValue; +}; + +/** Runtime-backed intent for writing one legacy-compatible node property. */ +export default class NodePropertyWriteIntent { + readonly owner: NodeRecord; + readonly key: LegacyNodePropertyKey; + readonly value: LegacyPropertyValue; + + constructor(fields: NodePropertyWriteIntentFields) { + const checkedFields = requireFields(fields); + this.owner = requireOwner(checkedFields.owner); + this.key = requireKey(checkedFields.key); + this.value = requireValue(checkedFields.value); + Object.freeze(this); + } + + /** Builds a write intent from the current public node-property API fields. */ + static fromLegacyProperty(nodeId: string, key: string, value: PropValue): NodePropertyWriteIntent { + return new NodePropertyWriteIntent({ + owner: NodeRecord.fromLegacyNodeId(nodeId), + key: new LegacyNodePropertyKey(key), + value: new LegacyPropertyValue(value), + }); + } + + /** Returns the current legacy node id target. */ + nodeId(): string { + return this.owner.id.toString(); + } + + /** Returns the current legacy property key target. */ + propertyKey(): string { + return this.key.toString(); + } + + /** Returns a defensive copy of the property value. */ + propertyValue(): PropValue { + return this.value.toPropValue(); + } +} + +/** Validates the write-intent constructor envelope. */ +function requireFields( + fields: NodePropertyWriteIntentFields | null | undefined, +): NodePropertyWriteIntentFields { + if (fields === null || fields === undefined) { + throw new WarpError('NodePropertyWriteIntent fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed node owner. */ +function requireOwner(owner: NodeRecord): NodeRecord { + if (!(owner instanceof NodeRecord)) { + throw new WarpError('NodePropertyWriteIntent owner must be a NodeRecord', 'E_VALIDATION'); + } + return owner; +} + +/** Requires a runtime-backed node property key. */ +function requireKey(key: LegacyNodePropertyKey): LegacyNodePropertyKey { + if (!(key instanceof LegacyNodePropertyKey)) { + throw new WarpError( + 'NodePropertyWriteIntent key must be a LegacyNodePropertyKey', + 'E_VALIDATION', + ); + } + return key; +} + +/** Requires a runtime-backed property value. */ +function requireValue(value: LegacyPropertyValue): LegacyPropertyValue { + if (!(value instanceof LegacyPropertyValue)) { + throw new WarpError( + 'NodePropertyWriteIntent value must be a LegacyPropertyValue', + 'E_VALIDATION', + ); + } + return value; +} diff --git a/src/domain/graph/publicGraphSubstrate.ts b/src/domain/graph/publicGraphSubstrate.ts index 189d2579..46c25213 100644 --- a/src/domain/graph/publicGraphSubstrate.ts +++ b/src/domain/graph/publicGraphSubstrate.ts @@ -7,6 +7,7 @@ export { default as ContentAttachmentPayload } from './ContentAttachmentPayload. export { default as ContentAttachmentRecord } from './ContentAttachmentRecord.ts'; export { default as ContentAttachmentSize } from './ContentAttachmentSize.ts'; export { default as ContentAttachmentWriteIntent } from './ContentAttachmentWriteIntent.ts'; +export { default as EdgePropertyWriteIntent } from './EdgePropertyWriteIntent.ts'; export { default as EdgeId } from './EdgeId.ts'; export { default as EdgeRecord } from './EdgeRecord.ts'; export { default as EdgeTypeId } from './EdgeTypeId.ts'; @@ -19,6 +20,7 @@ export { default as LegacyNodePropertyKey } from './LegacyNodePropertyKey.ts'; export { default as LegacyPropertyProjection } from './LegacyPropertyProjection.ts'; export { default as LegacyPropertyValue } from './LegacyPropertyValue.ts'; export { default as NodeId } from './NodeId.ts'; +export { default as NodePropertyWriteIntent } from './NodePropertyWriteIntent.ts'; export { default as NodeRecord } from './NodeRecord.ts'; export { default as NodeTypeId } from './NodeTypeId.ts'; export { default as VisibleEdgePropertyRecord } from './VisibleEdgePropertyRecord.ts'; @@ -53,6 +55,11 @@ export type { export type { ContentAttachmentPayloadFields } from './ContentAttachmentPayload.ts'; export type { ContentAttachmentRecordFields } from './ContentAttachmentRecord.ts'; export type { ContentAttachmentEdgeWriteTarget } from './ContentAttachmentWriteIntent.ts'; +export type { + EdgePropertyWriteIntentFields, + EdgePropertyWriteTarget, + LegacyEdgePropertyWriteFields, +} from './EdgePropertyWriteIntent.ts'; export type { EdgeRecordFields, LegacyEdgeFields } from './EdgeRecord.ts'; export type { GraphAttachmentSetOpFields } from './GraphAttachmentSetOp.ts'; export type { GraphEdgeRecordSetOpFields } from './GraphEdgeRecordSetOp.ts'; @@ -61,6 +68,7 @@ export type { GraphOpAlgebraFields } from './GraphOpAlgebra.ts'; export type { GraphOperation } from './GraphOperation.ts'; export type { LegacyPropertyKeyClassification } from './LegacyPropertyKeyClassification.ts'; export type { LegacyPropertyProjectionFields } from './LegacyPropertyProjection.ts'; +export type { NodePropertyWriteIntentFields } from './NodePropertyWriteIntent.ts'; export type { NodeRecordFields } from './NodeRecord.ts'; export type { VisibleEdgePropertyRecordFields } from './VisibleEdgePropertyRecord.ts'; export type { VisibleNodePropertyRecordFields } from './VisibleNodePropertyRecord.ts'; diff --git a/test/unit/domain/graph/PropertyWriteIntent.test.ts b/test/unit/domain/graph/PropertyWriteIntent.test.ts new file mode 100644 index 00000000..5c60f490 --- /dev/null +++ b/test/unit/domain/graph/PropertyWriteIntent.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import EdgePropertyWriteIntent from '../../../../src/domain/graph/EdgePropertyWriteIntent.ts'; +import NodePropertyWriteIntent from '../../../../src/domain/graph/NodePropertyWriteIntent.ts'; + +describe('property write intents', () => { + it('names node property writes as frozen runtime-backed intents', () => { + const source = { nested: ['ready'] }; + const intent = NodePropertyWriteIntent.fromLegacyProperty('node:1', 'status', source); + source.nested.push('mutated'); + + expect(intent.nodeId()).toBe('node:1'); + expect(intent.propertyKey()).toBe('status'); + expect(intent.propertyValue()).toEqual({ nested: ['ready'] }); + expect(Object.isFrozen(intent)).toBe(true); + }); + + it('names edge property writes as frozen runtime-backed intents', () => { + const intent = EdgePropertyWriteIntent.fromLegacyProperty({ + from: 'node:1', + to: 'node:2', + label: 'rel', + key: 'weight', + value: 3, + }); + + expect(intent.edgeTarget()).toEqual({ + from: 'node:1', + to: 'node:2', + label: 'rel', + }); + expect(intent.propertyKey()).toBe('weight'); + expect(intent.propertyValue()).toBe(3); + expect(Object.isFrozen(intent)).toBe(true); + }); + + it('rejects malformed node property write carriers', () => { + expect(() => NodePropertyWriteIntent.fromLegacyProperty('', 'status', 'ready')).toThrow(/NodeId/); + expect(() => NodePropertyWriteIntent.fromLegacyProperty('node:1', '', 'ready')).toThrow( + /LegacyNodePropertyKey/, + ); + expect(() => NodePropertyWriteIntent.fromLegacyProperty('node\0bad', 'status', 'ready')).toThrow( + /NodeId/, + ); + expect(() => NodePropertyWriteIntent.fromLegacyProperty('node:1', 'bad\0key', 'ready')).toThrow( + /LegacyNodePropertyKey/, + ); + expect(() => { + // @ts-expect-error exercising runtime rejection of invalid value carriers + NodePropertyWriteIntent.fromLegacyProperty('node:1', 'status', new InvalidPropertyCarrier()); + }).toThrow(/LegacyPropertyValue/); + }); + + it('rejects malformed edge property write carriers', () => { + expect(() => EdgePropertyWriteIntent.fromLegacyProperty({ + from: '', + to: 'node:2', + label: 'rel', + key: 'weight', + value: 3, + })).toThrow(/NodeId/); + expect(() => EdgePropertyWriteIntent.fromLegacyProperty({ + from: 'node:1', + to: 'node\0bad', + label: 'rel', + key: 'weight', + value: 3, + })).toThrow(/EdgeId/); + expect(() => EdgePropertyWriteIntent.fromLegacyProperty({ + from: 'node:1', + to: 'node:2', + label: '', + key: 'weight', + value: 3, + })).toThrow(/EdgeTypeId/); + expect(() => EdgePropertyWriteIntent.fromLegacyProperty({ + from: 'node:1', + to: 'node:2', + label: 'rel', + key: '', + value: 3, + })).toThrow(/LegacyEdgePropertyKey/); + }); +}); + +class InvalidPropertyCarrier {} From f96844314485529c84d9583d4f2b7cbe2e066d8f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:26:25 -0700 Subject: [PATCH 03/11] Feat: Lower PatchBuilder properties through intents --- docs/BEARING.md | 2 +- ...8-patchbuilder-property-intent-lowering.md | 2 +- src/domain/services/PatchBuilder.ts | 94 +++++++++++-- .../PatchBuilderPropertyIntent.test.ts | 127 ++++++++++++++++++ 4 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 test/unit/domain/services/PatchBuilderPropertyIntent.test.ts diff --git a/docs/BEARING.md b/docs/BEARING.md index 5a98dff3..7b1f0301 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -195,7 +195,7 @@ and concrete checks live in `docs/invariants/`. [0179](design/0179-v18-state-reader-property-projection/v18-state-reader-property-projection.md). - [x] 32. Add property write intent nouns: [0180](design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md). -- [ ] 33. Route PatchBuilder property writes through intent lowering: +- [x] 33. Route PatchBuilder property writes through intent lowering: [0181](design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md). - [ ] 34. Cut graph-op algebra over to property projections: [0182](design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md). diff --git a/docs/design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md b/docs/design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md index 5c76e187..e77fd401 100644 --- a/docs/design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md +++ b/docs/design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md @@ -1,7 +1,7 @@ --- cycle: 0181 task_id: V18_patchbuilder_property_intent_lowering -status: Planned +status: Complete sponsors: human: James agent: Codex diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index f2099922..46f31e97 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -22,6 +22,8 @@ import ContentAttachmentOid from '../graph/ContentAttachmentOid.ts'; import ContentAttachmentPayload from '../graph/ContentAttachmentPayload.ts'; import ContentAttachmentSize from '../graph/ContentAttachmentSize.ts'; import ContentAttachmentWriteIntent from '../graph/ContentAttachmentWriteIntent.ts'; +import EdgePropertyWriteIntent from '../graph/EdgePropertyWriteIntent.ts'; +import NodePropertyWriteIntent from '../graph/NodePropertyWriteIntent.ts'; import type { OpV2, CanonicalOpV2 } from '../types/ops/unions.ts'; import { encodeEdgeKey, CONTENT_PROPERTY_KEY, CONTENT_MIME_PROPERTY_KEY, CONTENT_SIZE_PROPERTY_KEY, EFFECT_NODE_PREFIX } from './KeyCodec.ts'; import { lowerCanonicalOp } from './OpNormalizer.ts'; @@ -42,6 +44,7 @@ import type LoggerPort from '../../ports/LoggerPort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; +import type { PropValue } from '../types/PropValue.ts'; type ContentInput = AsyncIterable | ReadableStream | Uint8Array | string; type ContentMetadataInput = { mime?: string | null; size?: number | null }; @@ -238,23 +241,26 @@ export class PatchBuilder { setProperty(nodeId: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B this._assertNotCommitted(); - assertNoReservedBytes(nodeId, 'nodeId'); - assertNoReservedBytes(key, 'property key'); - this._ops.push(new NodePropSet(nodeId, key, value)); - this._observedOperands.add(nodeId); - this._writes.add(nodeId); + const intent = NodePropertyWriteIntent.fromLegacyProperty( + nodeId, + key, + requirePatchPropertyValue(value), + ); + this._lowerNodePropertyIntent(intent); return this; } setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B this._assertNotCommitted(); - assertNoReservedBytes(from, 'from node ID'); - assertNoReservedBytes(to, 'to node ID'); - assertNoReservedBytes(label, 'edge label'); - assertNoReservedBytes(key, 'property key'); + const intent = EdgePropertyWriteIntent.fromLegacyProperty({ + from, + to, + label, + key, + value: requirePatchPropertyValue(value), + }); const ek = this._assertEdgeExists(from, to, label); - this._ops.push(new EdgePropSet({ from, to, label, key, value })); - this._hasEdgeProps = true; + this._lowerEdgePropertyIntent(intent); this._observedOperands.add(ek); this._writes.add(ek); return this; @@ -342,6 +348,25 @@ export class PatchBuilder { this.setEdgeProperty(target.from, target.to, target.label, CONTENT_MIME_PROPERTY_KEY, intent.mime()); } + private _lowerNodePropertyIntent(intent: NodePropertyWriteIntent): void { + const nodeId = intent.nodeId(); + this._ops.push(new NodePropSet(nodeId, intent.propertyKey(), intent.propertyValue())); + this._observedOperands.add(nodeId); + this._writes.add(nodeId); + } + + private _lowerEdgePropertyIntent(intent: EdgePropertyWriteIntent): void { + const target = intent.edgeTarget(); + this._ops.push(new EdgePropSet({ + from: target.from, + to: target.to, + label: target.label, + key: intent.propertyKey(), + value: intent.propertyValue(), + })); + this._hasEdgeProps = true; + } + // ── Existence guards ─────────────────────────────────────────────── private _assertNodeExistsForContent(nodeId: string): void { @@ -429,6 +454,53 @@ export class PatchBuilder { get contentBlobs(): readonly string[] { return [...this._contentBlobs]; } } +/** Validates public patch property values before intent construction. */ +function requirePatchPropertyValue(value: unknown): PropValue { // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary + if (isScalarPatchPropertyValue(value)) { + return value; + } + if (value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => requirePatchPropertyValue(entry)); + } + if (isPlainPatchPropertyObject(value)) { + const record: { [key: string]: PropValue } = {}; + for (const [key, entry] of Object.entries(value)) { + record[key] = requirePatchPropertyValue(entry); + } + return record; + } + throw new PatchError('Property value must be property-compatible data', { + code: 'E_PATCH_INVALID_PROPERTY_VALUE', + }); +} + +/** Returns true for scalar property values. */ +function isScalarPatchPropertyValue( + value: unknown, // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary +): value is string | number | boolean | null { + return value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean'; +} + +/** Returns true for plain recursive property objects. */ +function isPlainPatchPropertyObject( + value: unknown, // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary +): value is { readonly [key: string]: unknown } { // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary + if (value === null || typeof value !== 'object') { + return false; + } + if (Array.isArray(value) || value instanceof Uint8Array) { + return false; + } + return Object.getPrototypeOf(value) === Object.prototype + || Object.getPrototypeOf(value) === null; +} + async function storeContentAttachmentPayload( blobStorage: BlobStoragePort, content: ContentInput, diff --git a/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts b/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts new file mode 100644 index 00000000..da6f4918 --- /dev/null +++ b/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import PatchError from '../../../../src/domain/errors/PatchError.ts'; +import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; +import { + encodeEdgeKey, + encodeLegacyEdgePropNode, +} from '../../../../src/domain/services/KeyCodec.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; +import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; + +const TEST_SHA = 'a'.repeat(40); + +describe('PatchBuilder property intent lowering', () => { + it('lowers node property intents to the current PropSet shape', () => { + const builder = createBuilder(null); + + builder.setProperty('node:1', 'status', 'ready'); + + const patch = builder.build(); + expect(patch.ops).toHaveLength(1); + const op = requirePropSet(patch.ops[0]); + expect(op.node).toBe('node:1'); + expect(op.key).toBe('status'); + expect(op.value).toBe('ready'); + expect(patch.schema).toBe(2); + }); + + it('lowers edge property intents to the current legacy edge PropSet shape', () => { + const state = stateWithEdge(); + const builder = createBuilder(state); + + builder.setEdgeProperty('node:1', 'node:2', 'rel', 'weight', 3); + + const patch = builder.build(); + expect(patch.ops).toHaveLength(1); + const op = requirePropSet(patch.ops[0]); + expect(op.node).toBe(encodeLegacyEdgePropNode('node:1', 'node:2', 'rel')); + expect(op.key).toBe('weight'); + expect(op.value).toBe(3); + expect(patch.schema).toBe(3); + }); + + it('rejects invalid node property values before appending operations', () => { + const builder = createBuilder(null); + + expect(() => { + builder.setProperty('node:1', 'status', new InvalidPropertyCarrier()); + }).toThrow(PatchError); + expect(builder.build().ops).toEqual([]); + }); + + it('rejects malformed edge targets before appending operations', () => { + const builder = createBuilder(stateWithEdge()); + + expect(() => { + builder.setEdgeProperty('', 'node:2', 'rel', 'weight', 3); + }).toThrow(/NodeId/); + expect(builder.build().ops).toEqual([]); + }); +}); + +function createBuilder(state: WarpState | null): PatchBuilder { + return new PatchBuilder({ + persistence: unusedPersistence(), + graphName: 'graph', + writerId: 'writer', + lamport: 1, + versionVector: VersionVector.empty(), + getCurrentState: () => state, + }); +} + +function stateWithEdge(): WarpState { + const state = WarpState.empty(); + state.nodeAlive.add('node:1', Dot.create('writer', 1)); + state.nodeAlive.add('node:2', Dot.create('writer', 2)); + state.edgeAlive.add(encodeEdgeKey('node:1', 'node:2', 'rel'), Dot.create('writer', 3)); + return state; +} + +function requirePropSet(op: object | undefined): PropSet { + if (op instanceof PropSet) { + return op; + } + throw new PatchError('Expected PropSet in test output', { code: 'E_TEST_EXPECTED_PROP_SET' }); +} + +function unusedPersistence() { + return { + commitNode: async () => TEST_SHA, + showNode: async () => '', + getNodeInfo: async () => ({ + sha: TEST_SHA, + message: '', + author: '', + date: '', + parents: [], + }), + logNodes: async () => '', + logNodesStream: async () => { + throw new PatchError('unused logNodesStream', { code: 'E_TEST_UNUSED_PORT' }); + }, + countNodes: async () => 0, + commitNodeWithTree: async () => TEST_SHA, + nodeExists: async () => true, + getCommitTree: async () => TEST_SHA, + ping: async () => ({ ok: true, latencyMs: 0 }), + writeBlob: async () => TEST_SHA, + readBlob: async () => new Uint8Array(), + writeTree: async () => TEST_SHA, + readTree: async () => ({}), + readTreeOids: async () => ({}), + get emptyTree() { + return TEST_SHA; + }, + updateRef: async () => {}, + readRef: async () => null, + deleteRef: async () => {}, + listRefs: async () => [], + compareAndSwapRef: async () => {}, + }; +} + +class InvalidPropertyCarrier {} From 29ecc878e2d1b0034dadb5d33c2acff0843e2afd Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:33:35 -0700 Subject: [PATCH 04/11] Feat: Cut graph op algebra to property projections --- docs/BEARING.md | 2 +- ...18-graph-op-projection-property-cutover.md | 2 +- .../graph/GraphContentAttachmentSetOp.ts | 41 +++++++++++ src/domain/graph/GraphEdgePropertySetOp.ts | 41 +++++++++++ src/domain/graph/GraphNodePropertySetOp.ts | 41 +++++++++++ src/domain/graph/GraphOpAlgebra.ts | 27 ++++++-- src/domain/graph/GraphOperation.ts | 11 ++- src/domain/graph/publicGraphSubstrate.ts | 15 ++++ .../services/GraphOpAlgebraProjection.ts | 60 +++++++++++++--- test/unit/domain/graph/GraphOpAlgebra.test.ts | 68 ++++++++++++++++++- .../services/GraphOpAlgebraProjection.test.ts | 41 ++++++++--- 11 files changed, 320 insertions(+), 29 deletions(-) create mode 100644 src/domain/graph/GraphContentAttachmentSetOp.ts create mode 100644 src/domain/graph/GraphEdgePropertySetOp.ts create mode 100644 src/domain/graph/GraphNodePropertySetOp.ts diff --git a/docs/BEARING.md b/docs/BEARING.md index 7b1f0301..0118c0f3 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -197,7 +197,7 @@ and concrete checks live in `docs/invariants/`. [0180](design/0180-v18-property-write-intent-nouns/v18-property-write-intent-nouns.md). - [x] 33. Route PatchBuilder property writes through intent lowering: [0181](design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md). -- [ ] 34. Cut graph-op algebra over to property projections: +- [x] 34. Cut graph-op algebra over to property projections: [0182](design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md). - [ ] 35. Close out legacy-property projection with evidence: [0183](design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md). diff --git a/docs/design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md b/docs/design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md index 0ff13aeb..9266c110 100644 --- a/docs/design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md +++ b/docs/design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md @@ -1,7 +1,7 @@ --- cycle: 0182 task_id: V18_graph_op_projection_property_cutover -status: Planned +status: Complete sponsors: human: James agent: Codex diff --git a/src/domain/graph/GraphContentAttachmentSetOp.ts b/src/domain/graph/GraphContentAttachmentSetOp.ts new file mode 100644 index 00000000..39daa345 --- /dev/null +++ b/src/domain/graph/GraphContentAttachmentSetOp.ts @@ -0,0 +1,41 @@ +import ContentAttachmentRecord from './ContentAttachmentRecord.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_CONTENT_ATTACHMENT_SET_OP = 'GraphContentAttachmentSet'; + +export type GraphContentAttachmentSetOpFields = { + readonly record: ContentAttachmentRecord; +}; + +/** Runtime-backed graph operation that records a typed content attachment. */ +export default class GraphContentAttachmentSetOp { + readonly type = GRAPH_CONTENT_ATTACHMENT_SET_OP; + readonly record: ContentAttachmentRecord; + + constructor(fields: GraphContentAttachmentSetOpFields) { + const checkedFields = requireFields(fields); + this.record = requireContentAttachmentRecord(checkedFields.record); + Object.freeze(this); + } +} + +/** Validates the graph-content operation constructor envelope. */ +function requireFields( + fields: GraphContentAttachmentSetOpFields | null | undefined, +): GraphContentAttachmentSetOpFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphContentAttachmentSetOp fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed content attachment record. */ +function requireContentAttachmentRecord(record: ContentAttachmentRecord): ContentAttachmentRecord { + if (!(record instanceof ContentAttachmentRecord)) { + throw new WarpError( + 'GraphContentAttachmentSetOp record must be a ContentAttachmentRecord', + 'E_VALIDATION', + ); + } + return record; +} diff --git a/src/domain/graph/GraphEdgePropertySetOp.ts b/src/domain/graph/GraphEdgePropertySetOp.ts new file mode 100644 index 00000000..449eb616 --- /dev/null +++ b/src/domain/graph/GraphEdgePropertySetOp.ts @@ -0,0 +1,41 @@ +import VisibleEdgePropertyRecord from './VisibleEdgePropertyRecord.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_EDGE_PROPERTY_SET_OP = 'GraphEdgePropertySet'; + +export type GraphEdgePropertySetOpFields = { + readonly record: VisibleEdgePropertyRecord; +}; + +/** Runtime-backed graph operation for an edge property compatibility fact. */ +export default class GraphEdgePropertySetOp { + readonly type = GRAPH_EDGE_PROPERTY_SET_OP; + readonly record: VisibleEdgePropertyRecord; + + constructor(fields: GraphEdgePropertySetOpFields) { + const checkedFields = requireFields(fields); + this.record = requireEdgePropertyRecord(checkedFields.record); + Object.freeze(this); + } +} + +/** Validates the graph-edge-property operation constructor envelope. */ +function requireFields( + fields: GraphEdgePropertySetOpFields | null | undefined, +): GraphEdgePropertySetOpFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphEdgePropertySetOp fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed visible edge property record. */ +function requireEdgePropertyRecord(record: VisibleEdgePropertyRecord): VisibleEdgePropertyRecord { + if (!(record instanceof VisibleEdgePropertyRecord)) { + throw new WarpError( + 'GraphEdgePropertySetOp record must be a VisibleEdgePropertyRecord', + 'E_VALIDATION', + ); + } + return record; +} diff --git a/src/domain/graph/GraphNodePropertySetOp.ts b/src/domain/graph/GraphNodePropertySetOp.ts new file mode 100644 index 00000000..e917b347 --- /dev/null +++ b/src/domain/graph/GraphNodePropertySetOp.ts @@ -0,0 +1,41 @@ +import VisibleNodePropertyRecord from './VisibleNodePropertyRecord.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_NODE_PROPERTY_SET_OP = 'GraphNodePropertySet'; + +export type GraphNodePropertySetOpFields = { + readonly record: VisibleNodePropertyRecord; +}; + +/** Runtime-backed graph operation for a node property compatibility fact. */ +export default class GraphNodePropertySetOp { + readonly type = GRAPH_NODE_PROPERTY_SET_OP; + readonly record: VisibleNodePropertyRecord; + + constructor(fields: GraphNodePropertySetOpFields) { + const checkedFields = requireFields(fields); + this.record = requireNodePropertyRecord(checkedFields.record); + Object.freeze(this); + } +} + +/** Validates the graph-node-property operation constructor envelope. */ +function requireFields( + fields: GraphNodePropertySetOpFields | null | undefined, +): GraphNodePropertySetOpFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphNodePropertySetOp fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a runtime-backed visible node property record. */ +function requireNodePropertyRecord(record: VisibleNodePropertyRecord): VisibleNodePropertyRecord { + if (!(record instanceof VisibleNodePropertyRecord)) { + throw new WarpError( + 'GraphNodePropertySetOp record must be a VisibleNodePropertyRecord', + 'E_VALIDATION', + ); + } + return record; +} diff --git a/src/domain/graph/GraphOpAlgebra.ts b/src/domain/graph/GraphOpAlgebra.ts index faba69ff..b594856b 100644 --- a/src/domain/graph/GraphOpAlgebra.ts +++ b/src/domain/graph/GraphOpAlgebra.ts @@ -1,6 +1,9 @@ import GraphAttachmentSetOp from './GraphAttachmentSetOp.ts'; +import GraphContentAttachmentSetOp from './GraphContentAttachmentSetOp.ts'; import GraphEdgeRecordSetOp from './GraphEdgeRecordSetOp.ts'; +import GraphEdgePropertySetOp from './GraphEdgePropertySetOp.ts'; import GraphNodeRecordSetOp from './GraphNodeRecordSetOp.ts'; +import GraphNodePropertySetOp from './GraphNodePropertySetOp.ts'; import WarpError from '../errors/WarpError.ts'; import type { GraphOperation } from './GraphOperation.ts'; @@ -43,12 +46,26 @@ function requireOperations( /** Requires a supported graph operation instance. */ function requireOperation(operation: GraphOperation): GraphOperation { - if ( - operation instanceof GraphNodeRecordSetOp - || operation instanceof GraphEdgeRecordSetOp - || operation instanceof GraphAttachmentSetOp - ) { + if (isRecordOperation(operation) || isProjectionOperation(operation)) { return operation; } throw new WarpError('GraphOpAlgebra operation must be a graph operation instance', 'E_VALIDATION'); } + +/** Returns true when the operation is a core graph record operation. */ +function isRecordOperation(operation: GraphOperation): boolean { + return ( + operation instanceof GraphNodeRecordSetOp + || operation instanceof GraphEdgeRecordSetOp + || operation instanceof GraphAttachmentSetOp + ); +} + +/** Returns true when the operation is a typed projection operation. */ +function isProjectionOperation(operation: GraphOperation): boolean { + return ( + operation instanceof GraphContentAttachmentSetOp + || operation instanceof GraphNodePropertySetOp + || operation instanceof GraphEdgePropertySetOp + ); +} diff --git a/src/domain/graph/GraphOperation.ts b/src/domain/graph/GraphOperation.ts index 136b9c77..b4ef535a 100644 --- a/src/domain/graph/GraphOperation.ts +++ b/src/domain/graph/GraphOperation.ts @@ -1,6 +1,15 @@ import type GraphAttachmentSetOp from './GraphAttachmentSetOp.ts'; +import type GraphContentAttachmentSetOp from './GraphContentAttachmentSetOp.ts'; import type GraphEdgeRecordSetOp from './GraphEdgeRecordSetOp.ts'; +import type GraphEdgePropertySetOp from './GraphEdgePropertySetOp.ts'; import type GraphNodeRecordSetOp from './GraphNodeRecordSetOp.ts'; +import type GraphNodePropertySetOp from './GraphNodePropertySetOp.ts'; /** Explicit graph operation algebra over record-backed graph substrate nouns. */ -export type GraphOperation = GraphNodeRecordSetOp | GraphEdgeRecordSetOp | GraphAttachmentSetOp; +export type GraphOperation = + | GraphNodeRecordSetOp + | GraphEdgeRecordSetOp + | GraphAttachmentSetOp + | GraphContentAttachmentSetOp + | GraphNodePropertySetOp + | GraphEdgePropertySetOp; diff --git a/src/domain/graph/publicGraphSubstrate.ts b/src/domain/graph/publicGraphSubstrate.ts index 46c25213..f43d6cca 100644 --- a/src/domain/graph/publicGraphSubstrate.ts +++ b/src/domain/graph/publicGraphSubstrate.ts @@ -12,8 +12,11 @@ export { default as EdgeId } from './EdgeId.ts'; export { default as EdgeRecord } from './EdgeRecord.ts'; export { default as EdgeTypeId } from './EdgeTypeId.ts'; export { default as GraphAttachmentSetOp } from './GraphAttachmentSetOp.ts'; +export { default as GraphContentAttachmentSetOp } from './GraphContentAttachmentSetOp.ts'; export { default as GraphEdgeRecordSetOp } from './GraphEdgeRecordSetOp.ts'; +export { default as GraphEdgePropertySetOp } from './GraphEdgePropertySetOp.ts'; export { default as GraphNodeRecordSetOp } from './GraphNodeRecordSetOp.ts'; +export { default as GraphNodePropertySetOp } from './GraphNodePropertySetOp.ts'; export { default as GraphOpAlgebra } from './GraphOpAlgebra.ts'; export { default as LegacyEdgePropertyKey } from './LegacyEdgePropertyKey.ts'; export { default as LegacyNodePropertyKey } from './LegacyNodePropertyKey.ts'; @@ -32,12 +35,21 @@ export { export { GRAPH_ATTACHMENT_SET_OP, } from './GraphAttachmentSetOp.ts'; +export { + GRAPH_CONTENT_ATTACHMENT_SET_OP, +} from './GraphContentAttachmentSetOp.ts'; export { GRAPH_EDGE_RECORD_SET_OP, } from './GraphEdgeRecordSetOp.ts'; +export { + GRAPH_EDGE_PROPERTY_SET_OP, +} from './GraphEdgePropertySetOp.ts'; export { GRAPH_NODE_RECORD_SET_OP, } from './GraphNodeRecordSetOp.ts'; +export { + GRAPH_NODE_PROPERTY_SET_OP, +} from './GraphNodePropertySetOp.ts'; export { LEGACY_PROPERTY_KEY_CONTENT_MIME, LEGACY_PROPERTY_KEY_CONTENT_OID, @@ -62,8 +74,11 @@ export type { } from './EdgePropertyWriteIntent.ts'; export type { EdgeRecordFields, LegacyEdgeFields } from './EdgeRecord.ts'; export type { GraphAttachmentSetOpFields } from './GraphAttachmentSetOp.ts'; +export type { GraphContentAttachmentSetOpFields } from './GraphContentAttachmentSetOp.ts'; export type { GraphEdgeRecordSetOpFields } from './GraphEdgeRecordSetOp.ts'; +export type { GraphEdgePropertySetOpFields } from './GraphEdgePropertySetOp.ts'; export type { GraphNodeRecordSetOpFields } from './GraphNodeRecordSetOp.ts'; +export type { GraphNodePropertySetOpFields } from './GraphNodePropertySetOp.ts'; export type { GraphOpAlgebraFields } from './GraphOpAlgebra.ts'; export type { GraphOperation } from './GraphOperation.ts'; export type { LegacyPropertyKeyClassification } from './LegacyPropertyKeyClassification.ts'; diff --git a/src/domain/services/GraphOpAlgebraProjection.ts b/src/domain/services/GraphOpAlgebraProjection.ts index 30d257aa..72ebed98 100644 --- a/src/domain/services/GraphOpAlgebraProjection.ts +++ b/src/domain/services/GraphOpAlgebraProjection.ts @@ -1,8 +1,13 @@ -import GraphAttachmentSetOp from '../graph/GraphAttachmentSetOp.ts'; +import GraphContentAttachmentSetOp from '../graph/GraphContentAttachmentSetOp.ts'; import GraphEdgeRecordSetOp from '../graph/GraphEdgeRecordSetOp.ts'; +import GraphEdgePropertySetOp from '../graph/GraphEdgePropertySetOp.ts'; import GraphNodeRecordSetOp from '../graph/GraphNodeRecordSetOp.ts'; +import GraphNodePropertySetOp from '../graph/GraphNodePropertySetOp.ts'; import GraphOpAlgebra from '../graph/GraphOpAlgebra.ts'; import WarpError from '../errors/WarpError.ts'; +import ContentAttachmentProjection from './ContentAttachmentProjection.ts'; +import EdgePropertyProjection from './EdgePropertyProjection.ts'; +import NodePropertyProjection from './NodePropertyProjection.ts'; import WarpState from './state/WarpState.ts'; import type { GraphOperation } from '../graph/GraphOperation.ts'; @@ -12,15 +17,11 @@ export default class GraphOpAlgebraProjection { static fromState(state: WarpState): GraphOpAlgebra { const checkedState = requireWarpState(state); const operations: GraphOperation[] = []; - for (const record of checkedState.nodeRecords()) { - operations.push(new GraphNodeRecordSetOp({ record })); - } - for (const record of checkedState.edgeRecords()) { - operations.push(new GraphEdgeRecordSetOp({ record })); - } - for (const record of checkedState.attachmentRecords()) { - operations.push(new GraphAttachmentSetOp({ record })); - } + appendNodeRecordOps(operations, checkedState); + appendEdgeRecordOps(operations, checkedState); + appendContentAttachmentOps(operations, checkedState); + appendNodePropertyOps(operations, checkedState); + appendEdgePropertyOps(operations, checkedState); return new GraphOpAlgebra({ operations }); } } @@ -32,3 +33,42 @@ function requireWarpState(state: WarpState): WarpState { } return state; } + +/** Appends node record operations in state iteration order. */ +function appendNodeRecordOps(operations: GraphOperation[], state: WarpState): void { + for (const record of state.nodeRecords()) { + operations.push(new GraphNodeRecordSetOp({ record })); + } +} + +/** Appends edge record operations in state iteration order. */ +function appendEdgeRecordOps(operations: GraphOperation[], state: WarpState): void { + for (const record of state.edgeRecords()) { + operations.push(new GraphEdgeRecordSetOp({ record })); + } +} + +/** Appends typed content attachment operations. */ +function appendContentAttachmentOps(operations: GraphOperation[], state: WarpState): void { + for (const record of ContentAttachmentProjection.fromState(state)) { + operations.push(new GraphContentAttachmentSetOp({ record })); + } +} + +/** Appends typed node property operations, excluding content compatibility aliases. */ +function appendNodePropertyOps(operations: GraphOperation[], state: WarpState): void { + for (const record of NodePropertyProjection.fromState(state)) { + if (!record.key.isContentCompatibilityKey()) { + operations.push(new GraphNodePropertySetOp({ record })); + } + } +} + +/** Appends typed edge property operations, excluding content compatibility aliases. */ +function appendEdgePropertyOps(operations: GraphOperation[], state: WarpState): void { + for (const record of EdgePropertyProjection.fromState(state)) { + if (!record.key.isContentCompatibilityKey()) { + operations.push(new GraphEdgePropertySetOp({ record })); + } + } +} diff --git a/test/unit/domain/graph/GraphOpAlgebra.test.ts b/test/unit/domain/graph/GraphOpAlgebra.test.ts index 8749124f..83080a2c 100644 --- a/test/unit/domain/graph/GraphOpAlgebra.test.ts +++ b/test/unit/domain/graph/GraphOpAlgebra.test.ts @@ -3,15 +3,26 @@ import { describe, expect, it } from 'vitest'; import AttachmentKey from '../../../../src/domain/graph/AttachmentKey.ts'; import AttachmentRecord from '../../../../src/domain/graph/AttachmentRecord.ts'; import AttachmentSchemaVersion from '../../../../src/domain/graph/AttachmentSchemaVersion.ts'; +import ContentAttachmentOid from '../../../../src/domain/graph/ContentAttachmentOid.ts'; +import ContentAttachmentPayload from '../../../../src/domain/graph/ContentAttachmentPayload.ts'; +import ContentAttachmentRecord from '../../../../src/domain/graph/ContentAttachmentRecord.ts'; import EdgeRecord from '../../../../src/domain/graph/EdgeRecord.ts'; import GraphAttachmentSetOp from '../../../../src/domain/graph/GraphAttachmentSetOp.ts'; +import GraphContentAttachmentSetOp from '../../../../src/domain/graph/GraphContentAttachmentSetOp.ts'; import GraphEdgeRecordSetOp from '../../../../src/domain/graph/GraphEdgeRecordSetOp.ts'; +import GraphEdgePropertySetOp from '../../../../src/domain/graph/GraphEdgePropertySetOp.ts'; import GraphNodeRecordSetOp from '../../../../src/domain/graph/GraphNodeRecordSetOp.ts'; +import GraphNodePropertySetOp from '../../../../src/domain/graph/GraphNodePropertySetOp.ts'; import GraphOpAlgebra from '../../../../src/domain/graph/GraphOpAlgebra.ts'; +import LegacyEdgePropertyKey from '../../../../src/domain/graph/LegacyEdgePropertyKey.ts'; +import LegacyNodePropertyKey from '../../../../src/domain/graph/LegacyNodePropertyKey.ts'; +import LegacyPropertyValue from '../../../../src/domain/graph/LegacyPropertyValue.ts'; import NodeRecord from '../../../../src/domain/graph/NodeRecord.ts'; +import VisibleEdgePropertyRecord from '../../../../src/domain/graph/VisibleEdgePropertyRecord.ts'; +import VisibleNodePropertyRecord from '../../../../src/domain/graph/VisibleNodePropertyRecord.ts'; describe('GraphOpAlgebra', () => { - it('names graph node, edge, and attachment operations as runtime-backed values', () => { + it('names graph node, edge, attachment, content, and property operations as runtime-backed values', () => { const nodeRecord = NodeRecord.fromLegacyNodeId('node:a'); const edgeRecord = EdgeRecord.fromLegacyEdge({ from: 'node:a', to: 'node:b', label: 'knows' }); const attachmentRecord = new AttachmentRecord({ @@ -20,20 +31,49 @@ describe('GraphOpAlgebra', () => { value: 'A', schemaVersion: AttachmentSchemaVersion.current(), }); + const contentRecord = new ContentAttachmentRecord({ + owner: nodeRecord, + payload: new ContentAttachmentPayload({ + oid: new ContentAttachmentOid('oid-a'), + mime: null, + size: null, + }), + }); + const nodePropertyRecord = new VisibleNodePropertyRecord({ + owner: nodeRecord, + key: new LegacyNodePropertyKey('title'), + value: new LegacyPropertyValue('A'), + }); + const edgePropertyRecord = new VisibleEdgePropertyRecord({ + owner: edgeRecord, + key: new LegacyEdgePropertyKey('weight'), + value: new LegacyPropertyValue(7), + }); const nodeOp = new GraphNodeRecordSetOp({ record: nodeRecord }); const edgeOp = new GraphEdgeRecordSetOp({ record: edgeRecord }); const attachmentOp = new GraphAttachmentSetOp({ record: attachmentRecord }); - const algebra = new GraphOpAlgebra({ operations: [nodeOp, edgeOp, attachmentOp] }); + const contentOp = new GraphContentAttachmentSetOp({ record: contentRecord }); + const nodePropertyOp = new GraphNodePropertySetOp({ record: nodePropertyRecord }); + const edgePropertyOp = new GraphEdgePropertySetOp({ record: edgePropertyRecord }); + const algebra = new GraphOpAlgebra({ + operations: [nodeOp, edgeOp, attachmentOp, contentOp, nodePropertyOp, edgePropertyOp], + }); expect(algebra.operations.map((operation) => operation.type)).toEqual([ 'GraphNodeRecordSet', 'GraphEdgeRecordSet', 'GraphAttachmentSet', + 'GraphContentAttachmentSet', + 'GraphNodePropertySet', + 'GraphEdgePropertySet', ]); expect(algebra.operations[0]).toBeInstanceOf(GraphNodeRecordSetOp); expect(algebra.operations[1]).toBeInstanceOf(GraphEdgeRecordSetOp); expect(algebra.operations[2]).toBeInstanceOf(GraphAttachmentSetOp); + expect(algebra.operations[3]).toBeInstanceOf(GraphContentAttachmentSetOp); + expect(algebra.operations[4]).toBeInstanceOf(GraphNodePropertySetOp); + expect(algebra.operations[5]).toBeInstanceOf(GraphEdgePropertySetOp); expect(Object.isFrozen(algebra.operations)).toBe(true); expect(Object.isFrozen(algebra)).toBe(true); }); @@ -51,6 +91,18 @@ describe('GraphOpAlgebra', () => { // @ts-expect-error exercising runtime validation new GraphAttachmentSetOp({ record: { key: 'title' } }); }).toThrow(/AttachmentRecord/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphContentAttachmentSetOp({ record: { payload: 'oid-a' } }); + }).toThrow(/ContentAttachmentRecord/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphNodePropertySetOp({ record: { key: 'title' } }); + }).toThrow(/VisibleNodePropertyRecord/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphEdgePropertySetOp({ record: { key: 'weight' } }); + }).toThrow(/VisibleEdgePropertyRecord/); expect(() => { // @ts-expect-error exercising runtime validation new GraphOpAlgebra({ operations: [{ type: 'NodePropSet' }] }); @@ -70,6 +122,18 @@ describe('GraphOpAlgebra', () => { // @ts-expect-error exercising runtime validation new GraphAttachmentSetOp(undefined); }).toThrow(/fields must be provided/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphContentAttachmentSetOp(null); + }).toThrow(/fields must be provided/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphNodePropertySetOp(undefined); + }).toThrow(/fields must be provided/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphEdgePropertySetOp(null); + }).toThrow(/fields must be provided/); expect(() => { // @ts-expect-error exercising runtime validation new GraphOpAlgebra(null); diff --git a/test/unit/domain/services/GraphOpAlgebraProjection.test.ts b/test/unit/domain/services/GraphOpAlgebraProjection.test.ts index 9d4e2f99..055f002b 100644 --- a/test/unit/domain/services/GraphOpAlgebraProjection.test.ts +++ b/test/unit/domain/services/GraphOpAlgebraProjection.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from 'vitest'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import GraphAttachmentSetOp from '../../../../src/domain/graph/GraphAttachmentSetOp.ts'; +import GraphContentAttachmentSetOp from '../../../../src/domain/graph/GraphContentAttachmentSetOp.ts'; import GraphEdgeRecordSetOp from '../../../../src/domain/graph/GraphEdgeRecordSetOp.ts'; +import GraphEdgePropertySetOp from '../../../../src/domain/graph/GraphEdgePropertySetOp.ts'; import GraphNodeRecordSetOp from '../../../../src/domain/graph/GraphNodeRecordSetOp.ts'; +import GraphNodePropertySetOp from '../../../../src/domain/graph/GraphNodePropertySetOp.ts'; import GraphOpAlgebraProjection from '../../../../src/domain/services/GraphOpAlgebraProjection.ts'; -import { encodeEdgeKey, encodeEdgePropKey, encodePropKey } from '../../../../src/domain/services/KeyCodec.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, + EDGE_PROP_PREFIX, + encodeEdgeKey, + encodeEdgePropKey, + encodePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; import { EventId } from '../../../../src/domain/utils/EventId.ts'; @@ -18,10 +28,20 @@ describe('GraphOpAlgebraProjection', () => { state.nodeAlive.add('node:b', Dot.create('writer-a', 2)); state.edgeAlive.add(encodeEdgeKey('node:a', 'node:b', 'knows'), Dot.create('writer-a', 3)); state.prop.set(encodePropKey('node:a', 'title'), { eventId: event(4), value: 'A' }); - state.prop.set(encodeEdgePropKey('node:a', 'node:b', 'knows', 'weight'), { + state.prop.set(encodePropKey('node:a', CONTENT_PROPERTY_KEY), { eventId: event(5), value: 'oid-a' }); + state.prop.set(encodePropKey('node:a', CONTENT_MIME_PROPERTY_KEY), { eventId: event(5), + value: 'text/plain', + }); + state.prop.set(encodePropKey('node:a', CONTENT_SIZE_PROPERTY_KEY), { eventId: event(5), value: 12 }); + state.prop.set(encodeEdgePropKey('node:a', 'node:b', 'knows', 'weight'), { + eventId: event(6), value: 7, }); + state.prop.set(`${EDGE_PROP_PREFIX}node:a\0node:b\0knows\0bad\0extra`, { + eventId: event(7), + value: 'ignored', + }); const algebra = GraphOpAlgebraProjection.fromState(state); @@ -29,17 +49,19 @@ describe('GraphOpAlgebraProjection', () => { 'GraphNodeRecordSet', 'GraphNodeRecordSet', 'GraphEdgeRecordSet', - 'GraphAttachmentSet', - 'GraphAttachmentSet', + 'GraphContentAttachmentSet', + 'GraphNodePropertySet', + 'GraphEdgePropertySet', ]); expect(algebra.operations[0]).toBeInstanceOf(GraphNodeRecordSetOp); expect(algebra.operations[1]).toBeInstanceOf(GraphNodeRecordSetOp); expect(algebra.operations[2]).toBeInstanceOf(GraphEdgeRecordSetOp); - expect(algebra.operations[3]).toBeInstanceOf(GraphAttachmentSetOp); - expect(algebra.operations[4]).toBeInstanceOf(GraphAttachmentSetOp); + expect(algebra.operations[3]).toBeInstanceOf(GraphContentAttachmentSetOp); + expect(algebra.operations[4]).toBeInstanceOf(GraphNodePropertySetOp); + expect(algebra.operations[5]).toBeInstanceOf(GraphEdgePropertySetOp); }); - it('does not expose legacy property ops as the graph substrate contract', () => { + it('does not expose legacy property ops or raw attachments as the graph substrate contract', () => { const state = WarpState.empty(); state.nodeAlive.add('node:a', Dot.create('writer-a', 1)); state.prop.set(encodePropKey('node:a', 'title'), { eventId: event(2), value: 'A' }); @@ -48,7 +70,8 @@ describe('GraphOpAlgebraProjection', () => { .operations .map((operation) => operation.type); - expect(typeNames).toEqual(['GraphNodeRecordSet', 'GraphAttachmentSet']); + expect(typeNames).toEqual(['GraphNodeRecordSet', 'GraphNodePropertySet']); + expect(typeNames).not.toContain('GraphAttachmentSet'); expect(typeNames).not.toContain('NodePropSet'); expect(typeNames).not.toContain('EdgePropSet'); expect(typeNames).not.toContain('PropSet'); From ffab0c89075e867db76b7bad240b299d43fbdc9b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:47:36 -0700 Subject: [PATCH 05/11] Feat: Close v18 property projection with evidence --- CHANGELOG.md | 19 +++++ docs/BEARING.md | 53 ++++++++++---- .../v18-property-projection-closeout.md | 61 ++++++++++++++-- .../PROTO_legacy-props-as-projection.md | 40 ++++++++++- src/domain/services/PatchBuilder.ts | 6 ++ src/domain/services/TranslationCost.ts | 13 ++-- src/domain/services/controllers/QueryReads.ts | 2 +- .../services/query/StateQueryReadModel.ts | 14 ++-- src/domain/services/state/StateReader.ts | 20 +++--- .../services/state/StateReaderContext.ts | 69 ++++++++++++++++++- .../domain/WarpGraph.coverageGaps.test.ts | 2 + .../QueryReadsPropertyProjection.test.ts | 2 + ...teQueryReadModelPropertyProjection.test.ts | 68 ++++++++++++++++++ 13 files changed, 318 insertions(+), 51 deletions(-) create mode 100644 test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a6cfdc..1a13f51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- V18 property projection closeout now records the remaining raw + legacy-property boundaries as compatibility, serialization, replay, + reducer, index, or migration-source boundaries before graph-model migration + work begins. +- V18 graph-op algebra projection now emits typed content, node-property, and + edge-property operation nouns instead of exposing legacy property-map entries + as graph substrate operations. +- V18 generic property writes now construct runtime-backed node and edge + property write intent nouns before lowering to the existing legacy + compatibility operation shape. +- V18 state-reader property and content views now route through typed + projection records instead of decoding raw legacy property keys directly. - V18 query reads now route linear node property reads, edge property reads, and edge-list property payloads through projection-backed compatibility records instead of decoding raw property keys in the query controller. @@ -70,6 +82,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 property projection closeout now routes `StateQueryReadModel.nodeProps`, + translation-cost property-key accounting, and public property counts through + property projection records so malformed compatibility keys cannot leak into + live read-model property bags. +- V18 property projection closeout now preserves state-reader support for + immutable `SnapshotWarpState` sources and preserves PatchBuilder + reserved-byte validation errors before property write intent construction. - V18 property projection review follow-up now preserves tolerant public property-query misses, scopes targeted projection materialization to requested owners, skips malformed edge-property projection entries, shares legacy diff --git a/docs/BEARING.md b/docs/BEARING.md index 0118c0f3..e76c6966 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,17 +39,17 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Branch: `main` +- Branch: `v18-continuum-slices-31-35` - Base branch: `main` -- Current `origin/main`: `7d6cf669` -- Latest merged PR: #99, v18 property projection read surface +- Current `origin/main`: `35e5a6a9` +- Latest merged PR: #100, post-PR-99 BEARING cleanup checkpoint - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0178-v18-query-property-projection-reads` -- Current work: cleaned-up post-PR-99 boundary on `main`; next - implementation branch should start at slice 31. -- Cleanup checkpoint: before this signpost update branch, there were no open - PRs and remote refs had been pruned to `origin/main`. + `0183-v18-property-projection-closeout` +- Current work: v18 slices 31 through 35 are implemented on this branch and + ready for PR verification. +- Cleanup checkpoint: before this slice branch, there were no open PRs and + remote refs had been pruned to `origin/main`. The current v18 graph-model posture is: @@ -65,10 +65,19 @@ The current v18 graph-model posture is: - Runtime-backed legacy property projection nouns exist. - Node and edge property projections exist. - Public query property reads use projection-backed compatibility records. +- State-reader property and content views use projection-backed compatibility + records. +- Generic node and edge property writes construct runtime-backed write intents + before lowering to legacy compatibility operations. +- Graph-op algebra projection emits typed content and property operation + nouns, not raw property-map entries. +- Query read-model node props, translation-cost property-key accounting, and + public property counts use property projection nouns. That is useful progress, not a finish line. The repo still needs property -projection beyond query reads, graph-model migration tooling, and genesis -replay equivalence before v18 can make stronger compatibility claims. +projection beyond replay/serialization boundaries, graph-model migration +tooling, and genesis replay equivalence before v18 can make stronger +compatibility claims. ## What Just Shipped @@ -101,15 +110,29 @@ PR #99 landed v18 slices 26 through 30: malformed-record skipping, shared legacy content keys, and plain-object property carrier guards. +This branch implements v18 slices 31 through 35: + +- state-reader node, edge, and content property views route through typed + projections; +- runtime-backed node and edge property write intent nouns exist; +- `PatchBuilder` generic property writes lower through those intents while + preserving the existing patch wire shape; +- graph-op algebra projection emits typed content and property operation + nouns; +- closeout routed the remaining live read-model property views through + projections and documented the remaining raw legacy-property boundaries. + ## What Feels Wrong -- Some non-query read surfaces still have direct raw legacy property - interpretation, especially state-reader context code. -- Generic property writes still lower directly to legacy property operations; - content writes are intent-backed, but property writes are not. - Content persistence still uses legacy `_content*` compatibility properties. Typed reads and writes exist over that plane, but the storage cutover is not complete. +- Temporal replay still extracts node snapshots from the raw legacy property + map because historical replay tests carry pre-codec inline fixture classes + that are not `PropValue`-honest enough for `LegacyPropertyValue`. +- Checkpoint, serializer, state-diff, visible-scope, logical-index, + reducer/op-strategy, and content-projection code still touch the raw + property map as named compatibility or migration boundaries. - The v18 migration tool does not exist yet. Starting with a write-capable script would be reckless; the next migration work must be dry-run first. - Genesis replay equivalence has not been proven. Migration cannot be trusted @@ -199,7 +222,7 @@ and concrete checks live in `docs/invariants/`. [0181](design/0181-v18-patchbuilder-property-intent-lowering/v18-patchbuilder-property-intent-lowering.md). - [x] 34. Cut graph-op algebra over to property projections: [0182](design/0182-v18-graph-op-projection-property-cutover/v18-graph-op-projection-property-cutover.md). -- [ ] 35. Close out legacy-property projection with evidence: +- [x] 35. Close out legacy-property projection with evidence: [0183](design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md). - [ ] 36. Add graph-model migration manifest nouns: [0184](design/0184-v18-graph-model-migration-manifest/v18-graph-model-migration-manifest.md). diff --git a/docs/design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md b/docs/design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md index d3184c49..9e604154 100644 --- a/docs/design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md +++ b/docs/design/0183-v18-property-projection-closeout/v18-property-projection-closeout.md @@ -1,7 +1,7 @@ --- cycle: 0183 task_id: V18_property_projection_closeout -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -82,6 +82,11 @@ rg "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain The expected failing evidence is any call site outside named projection or migration inventory boundaries. +The closeout audit found one live read-model leak: `StateQueryReadModel` +still read `state.prop` directly and accepted malformed compatibility keys +as visible node properties. The regression now proves that malformed node +and edge compatibility keys are skipped through `NodePropertyProjection`. + ## GREEN Plan Fix or document each remaining direct read. Update docs with precise language: @@ -91,6 +96,43 @@ algebra. Mark the backlog item complete only if its acceptance criteria are met. +## Evidence + +Public and observer-facing property views now route through projection nouns: + +- `QueryReads` node props, edge props, edge-list props, and property counts; +- `StateReaderContext` node props, edge props, and content views; +- `StateQueryReadModel.nodeProps`; +- `TranslationCost` node property-key accounting; +- `GraphOpAlgebraProjection` typed content, node-property, and edge-property + operations. + +The full unit suite also caught two integration details that targeted checks +missed. `createStateReader()` still needs to accept immutable +`SnapshotWarpState` values returned by coordinate and strand materialization, +so the closeout hydrates those snapshots into projection-local `WarpState` +values before invoking projection nouns. `PatchBuilder` also keeps the +reserved-byte validation errors from the public API before property write +intent construction, so the intent cutover does not change caller-visible +validation behavior. + +The remaining direct raw-property sites are deliberately bounded: + +- `ContentAttachmentProjection` reads legacy `_content*` compatibility keys + until content persistence migrates; +- reducers, op strategies, and prop helper modules own legacy compatibility + mutation; +- checkpoint serializers, state serializers, state diffs, visible-state + scoping, and logical-index build code preserve or transform raw state; +- `TemporalQuery` replay snapshots still accept pre-codec inline fixture + classes that strict `LegacyPropertyValue` projection nouns reject; +- `PatchBuilderValidation` scans raw compatibility keys for delete guards; +- `KeyCodec` owns the legacy encoding and decoding functions. + +Those are not graph-substrate truth claims. They are compatibility, +serialization, replay, reducer, or migration-source boundaries for the next +batch to inventory before any write-capable migration exists. + ## Verification ```text @@ -110,12 +152,21 @@ git diff --check HEAD - Documentation no longer describes property bags as substrate truth. - The next slice can start graph-model migration manifest work. +## Closeout Outcome + +The slice closes the legacy-property projection backlog for public property +views and graph-op algebra. It does not close raw legacy property storage. +That storage is the explicit input to slices 36 through 40: migration +manifest, source inventory, dry-run planner, ordered history input, and +manifest serialization. + ## SSJS Scorecard -- Runtime-backed forms: green when all public property views use projection +- Runtime-backed forms: green; public property views use projection records. -- Boundary validation: green when legacy decoding is bounded. -- Behavior ownership: green when closeout records source ownership clearly. +- Boundary validation: green; remaining legacy decoding is bounded and + recorded. +- Behavior ownership: green; closeout records source ownership clearly. - Message parsing: green; no message parsing. - Ambient time or entropy: green; no code that uses ambient sources. -- Fake shape trust or cast-cosplay: green when no casts are introduced. +- Fake shape trust or cast-cosplay: green; no casts are introduced. diff --git a/docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md b/docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md index e15a489b..ed2833ff 100644 --- a/docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md +++ b/docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md @@ -1,5 +1,6 @@ --- id: PROTO_legacy-props-as-projection +status: complete blocked_by: - PROTO_attachment-plane-substrate blocks: @@ -17,12 +18,45 @@ over the attachment plane. ## Done looks like -- property-bag reads are projection helpers, not substrate truth -- graph writes no longer depend on prop-bag semantics -- read surfaces document clearly which property views are compatibility +- [x] property-bag reads are projection helpers, not substrate truth +- [x] graph writes no longer depend on prop-bag semantics +- [x] read surfaces document clearly which property views are compatibility projections and which are substrate facts ## Starting points - `src/domain/services/controllers/QueryReads.ts` - `src/domain/capabilities/QueryCapability.ts` + +## Closeout Evidence + +Completed by v18 slices 27 through 35: + +- `LegacyNodePropertyKey`, `LegacyEdgePropertyKey`, `LegacyPropertyValue`, + `VisibleNodePropertyRecord`, `VisibleEdgePropertyRecord`, and + `LegacyPropertyProjection` name the compatibility view. +- `NodePropertyProjection` and `EdgePropertyProjection` project visible + `WarpState` property facts without treating raw keys as substrate truth. +- `QueryReads`, `StateReaderContext`, `StateQueryReadModel`, and + `TranslationCost` now consume projection records for public property views + and property-key accounting. +- `NodePropertyWriteIntent` and `EdgePropertyWriteIntent` move generic + property writes through runtime-backed intent nouns before compatibility + lowering. +- `GraphOpAlgebraProjection` emits typed content and property operation nouns + rather than raw property-map entries. + +## Remaining Raw Boundaries + +The source audit still finds raw `state.prop` access in named compatibility +and migration-source boundaries: + +- reducer and op-strategy mutation paths; +- checkpoint and state serialization; +- state diffing, visible-state scoping, and logical-index build; +- content attachment projection over `_content*` compatibility keys; +- temporal replay snapshots that still accept pre-codec inline fixture values. + +Those are intentionally left for the graph-model migration batch. This backlog +item is closed for public property views and graph-op algebra, not for removal +of legacy property storage. diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index 46f31e97..8aee8ab3 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -241,6 +241,8 @@ export class PatchBuilder { setProperty(nodeId: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B this._assertNotCommitted(); + assertNoReservedBytes(nodeId, 'nodeId'); + assertNoReservedBytes(key, 'key'); const intent = NodePropertyWriteIntent.fromLegacyProperty( nodeId, key, @@ -252,6 +254,10 @@ export class PatchBuilder { setEdgeProperty(from: string, to: string, label: string, key: string, value: unknown): PatchBuilder { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B this._assertNotCommitted(); + assertNoReservedBytes(from, 'from node ID'); + assertNoReservedBytes(to, 'to node ID'); + assertNoReservedBytes(label, 'edge label'); + assertNoReservedBytes(key, 'key'); const intent = EdgePropertyWriteIntent.fromLegacyProperty({ from, to, diff --git a/src/domain/services/TranslationCost.ts b/src/domain/services/TranslationCost.ts index 90391930..66182e97 100644 --- a/src/domain/services/TranslationCost.ts +++ b/src/domain/services/TranslationCost.ts @@ -13,7 +13,8 @@ * @see Paper IV, Section 4 -- Directed rulial cost */ -import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.ts'; +import { decodeEdgeKey } from './KeyCodec.ts'; +import NodePropertyProjection from './NodePropertyProjection.ts'; import { matchGlob } from '../utils/matchGlob.ts'; import QueryError from '../errors/QueryError.ts'; import type WarpState from './state/WarpState.ts'; @@ -73,14 +74,8 @@ function isKeyVisible(key: string, exposeSet: Set | null, redactSet: Set */ function collectNodePropKeys(state: WarpState, nodeId: string): Map { const props = new Map(); - for (const [propKey] of state.prop) { - if (isEdgePropKey(propKey)) { - continue; - } - const decoded = decodePropKey(propKey); - if (decoded.nodeId === nodeId) { - props.set(decoded.propKey, true); - } + for (const record of NodePropertyProjection.forNode(state, nodeId)) { + props.set(record.key.toString(), true); } return props; } diff --git a/src/domain/services/controllers/QueryReads.ts b/src/domain/services/controllers/QueryReads.ts index a96e1d3f..a608a4c3 100644 --- a/src/domain/services/controllers/QueryReads.ts +++ b/src/domain/services/controllers/QueryReads.ts @@ -278,5 +278,5 @@ function buildEdgeList(state: WarpState, edgeProps: Map { const state = await ensureAndGetState(host); - return state.prop.size; + return NodePropertyProjection.fromState(state).length + EdgePropertyProjection.fromState(state).length; } diff --git a/src/domain/services/query/StateQueryReadModel.ts b/src/domain/services/query/StateQueryReadModel.ts index 6a0256f5..290eb60c 100644 --- a/src/domain/services/query/StateQueryReadModel.ts +++ b/src/domain/services/query/StateQueryReadModel.ts @@ -3,8 +3,9 @@ import type { NeighborEdge } from '../../../ports/NeighborProviderPort.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; import type WarpState from '../state/WarpState.ts'; import { createSnapshotPropValue } from '../ImmutableSnapshot.ts'; -import { decodeEdgeKey, decodePropKey } from '../KeyCodec.ts'; +import { decodeEdgeKey } from '../KeyCodec.ts'; import { matchGlob } from '../../utils/matchGlob.ts'; +import NodePropertyProjection from '../NodePropertyProjection.ts'; import type { QueryNeighborEntry, QueryNeighborOptions, @@ -141,13 +142,10 @@ export default class StateQueryReadModel implements QueryReadModel { const exposeSet = toFilterSet(this.#visibility.expose); const props: MutablePropertyBag = {}; - for (const [propKey, register] of this.#state.prop) { - const decoded = decodePropKey(propKey); - if ( - decoded.nodeId === nodeId && - isKeyVisible(decoded.propKey, redactSet, exposeSet) - ) { - props[decoded.propKey] = createSnapshotPropValue(register.value); + for (const record of NodePropertyProjection.forNode(this.#state, nodeId)) { + const propKey = record.key.toString(); + if (isKeyVisible(propKey, redactSet, exposeSet)) { + props[propKey] = createSnapshotPropValue(record.value.toPropValue()); } } diff --git a/src/domain/services/state/StateReader.ts b/src/domain/services/state/StateReader.ts index 771e9157..bfa425c0 100644 --- a/src/domain/services/state/StateReader.ts +++ b/src/domain/services/state/StateReader.ts @@ -1,9 +1,9 @@ import { projectState } from './StateSerializer.ts'; -import type WarpState from './WarpState.ts'; import { type ContentMeta, type NeighborEntry, type StateReaderContext, + type StateReaderSource, type VisiblePropertyBag, type VisibleEdgeRef, type VisibleEdgeView, @@ -18,6 +18,7 @@ import { createNodePropIndex, createNodePropertyRecords, createProjectionProps, + createStateReaderProjectionState, createVisibleEdges, edgeKeyFromRef, populateVisibleEdgeProps, @@ -189,20 +190,21 @@ function buildReaderApi(context: StateReaderContext): VisibleStateReader { // ── Context construction ───────────────────────────────────────────────────── /** Builds the full reader context from materialized state, including all indexes. */ -function buildReaderContext(state: WarpState): StateReaderContext { - const baseProjection = projectState(state); +function buildReaderContext(state: StateReaderSource): StateReaderContext { + const projectionState = createStateReaderProjectionState(state); + const baseProjection = projectState(projectionState); const projection = { nodes: baseProjection.nodes, edges: baseProjection.edges, - props: createProjectionProps(state), + props: createProjectionProps(projectionState), }; const visibleNodeIds = new Set(projection.nodes); const nodePropsById = createNodePropIndex(projection.nodes); const edgePropsByKey = createEdgePropIndex(projection.edges); const { outgoingByNode, incomingByNode } = createNeighborIndex(projection.nodes, projection.edges); - populateVisibleNodeProps(createNodePropertyRecords(state), nodePropsById); - populateVisibleEdgeProps(createEdgePropertyRecords(state), edgePropsByKey); + populateVisibleNodeProps(createNodePropertyRecords(projectionState), nodePropsById); + populateVisibleEdgeProps(createEdgePropertyRecords(projectionState), edgePropsByKey); return { projection, @@ -212,8 +214,8 @@ function buildReaderContext(state: WarpState): StateReaderContext { edges: createVisibleEdges(projection.edges, edgePropsByKey), outgoingByNode, incomingByNode, - nodeContentMetaById: createNodeContentMetaIndex(state, projection.nodes), - edgeContentMetaByKey: createEdgeContentMetaIndex(state, projection.edges), + nodeContentMetaById: createNodeContentMetaIndex(projectionState, projection.nodes), + edgeContentMetaByKey: createEdgeContentMetaIndex(projectionState, projection.edges), }; } @@ -225,6 +227,6 @@ function buildReaderContext(state: WarpState): StateReaderContext { * The reader exposes stable node/edge/property helpers and an entity-local * node inspection view without leaking OR-Set internals to higher layers. */ -export function createStateReader(state: WarpState): VisibleStateReader { +export function createStateReader(state: StateReaderSource): VisibleStateReader { return buildReaderApi(buildReaderContext(state)); } diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index c53d7f91..54bd14e8 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -1,16 +1,22 @@ +import ORSet from '../../crdt/ORSet.ts'; +import { LWWRegister } from '../../crdt/LWW.ts'; +import VersionVector from '../../crdt/VersionVector.ts'; import EdgeRecord from '../../graph/EdgeRecord.ts'; import NodeRecord from '../../graph/NodeRecord.ts'; +import WarpError from '../../errors/WarpError.ts'; import { encodeEdgeKey } from '../KeyCodec.ts'; import { createSnapshotPropValue } from '../ImmutableSnapshot.ts'; import ContentAttachmentProjection from '../ContentAttachmentProjection.ts'; import EdgePropertyProjection from '../EdgePropertyProjection.ts'; import NodePropertyProjection from '../NodePropertyProjection.ts'; +import ImmutableBytes from '../snapshot/ImmutableBytes.ts'; +import SnapshotWarpState from '../snapshot/SnapshotWarpState.ts'; import type ContentAttachmentRecord from '../../graph/ContentAttachmentRecord.ts'; import type { PropValue } from '../../types/PropValue.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; import type VisibleEdgePropertyRecord from '../../graph/VisibleEdgePropertyRecord.ts'; import type VisibleNodePropertyRecord from '../../graph/VisibleNodePropertyRecord.ts'; -import type WarpState from './WarpState.ts'; +import WarpState from './WarpState.ts'; // ── Public types ──────────────────────────────────────────────────────────── @@ -22,6 +28,7 @@ export type VisibleEdgeRef = { from: string; to: string; label: string }; export type VisiblePropertyBag = Readonly<{ [key: string]: SnapshotPropValue }>; type MutableVisiblePropertyBag = { [key: string]: SnapshotPropValue }; export type VisibleEdgeView = { from: string; to: string; label: string; props: VisiblePropertyBag }; +export type StateReaderSource = WarpState | SnapshotWarpState; type VisibleProjectionProp = { node: string; key: string; @@ -51,6 +58,17 @@ export function edgeKeyFromRef(edge: VisibleEdgeRef): string { return encodeEdgeKey(edge.from, edge.to, edge.label); } +/** Returns a projection-capable state from a live or immutable reader source. */ +export function createStateReaderProjectionState(state: StateReaderSource): WarpState { + if (state instanceof WarpState) { + return state; + } + if (state instanceof SnapshotWarpState) { + return warpStateFromSnapshot(state); + } + throw new WarpError('StateReader source must be a WarpState or SnapshotWarpState', 'E_VALIDATION'); +} + // ── Cloning helpers ────────────────────────────────────────────────────────── /** Shallow-clones a property bag. */ @@ -72,6 +90,55 @@ export function cloneNeighbors(entries: NeighborEntry[]): NeighborEntry[] { return entries.map((entry) => ({ ...entry })); } +/** Hydrates an immutable public snapshot into a projection-local WarpState. */ +function warpStateFromSnapshot(snapshot: SnapshotWarpState): WarpState { + return new WarpState({ + nodeAlive: orsetFromSnapshot(snapshot.nodeAlive), + edgeAlive: orsetFromSnapshot(snapshot.edgeAlive), + prop: propMapFromSnapshot(snapshot.prop), + observedFrontier: VersionVector.from(new Map(snapshot.observedFrontier.entries())), + edgeBirthEvent: new Map(snapshot.edgeBirthEvent), + }); +} + +/** Rebuilds an OR-Set from the immutable snapshot view. */ +function orsetFromSnapshot(snapshot: SnapshotWarpState['nodeAlive']): ORSet { + const entries = new Map>(); + for (const entry of snapshot.entries()) { + entries.set(entry.element, new Set(entry.dots)); + } + return new ORSet(entries, new Set(snapshot.tombstones())); +} + +/** Rebuilds the property map from immutable snapshot registers. */ +function propMapFromSnapshot( + snapshot: SnapshotWarpState['prop'], +): Map> { + const props = new Map>(); + for (const [key, register] of snapshot) { + props.set(key, new LWWRegister(register.eventId, propValueFromSnapshot(register.value))); + } + return props; +} + +/** Converts immutable snapshot values back into projection-local values. */ +function propValueFromSnapshot(value: SnapshotPropValue): PropValue { + if (value instanceof ImmutableBytes) { + return value.toUint8Array(); + } + if (Array.isArray(value)) { + return value.map((entry) => propValueFromSnapshot(entry)); + } + if (value !== null && typeof value === 'object') { + const props: { [key: string]: PropValue } = {}; + for (const [key, entry] of Object.entries(value)) { + props[key] = propValueFromSnapshot(entry); + } + return props; + } + return value; +} + // ── Index builders ─────────────────────────────────────────────────────────── /** Creates a map of node ID to empty property bags for population. */ diff --git a/test/unit/domain/WarpGraph.coverageGaps.test.ts b/test/unit/domain/WarpGraph.coverageGaps.test.ts index 42491598..61d5bad2 100644 --- a/test/unit/domain/WarpGraph.coverageGaps.test.ts +++ b/test/unit/domain/WarpGraph.coverageGaps.test.ts @@ -840,6 +840,8 @@ describe('WarpCore coverage gaps', () => { }); const state = createEmptyState(); + state.nodeAlive.add('user:alice', Dot.create('writer-1', 1)); + state.nodeAlive.add('user:bob', Dot.create('writer-1', 2)); state.prop.set('user:alice\0name', { value: 'Alice', eventId: ('writer-1:1' as any) }); state.prop.set('user:alice\0age', { value: 30, eventId: ('writer-1:2' as any) }); state.prop.set('user:bob\0name', { value: 'Bob', eventId: ('writer-1:3' as any) }); diff --git a/test/unit/domain/services/QueryReadsPropertyProjection.test.ts b/test/unit/domain/services/QueryReadsPropertyProjection.test.ts index 569842fc..c6c3a2f3 100644 --- a/test/unit/domain/services/QueryReadsPropertyProjection.test.ts +++ b/test/unit/domain/services/QueryReadsPropertyProjection.test.ts @@ -6,6 +6,7 @@ import { getEdgePropsImpl, getEdgesImpl, getNodePropsImpl, + getPropertyCountImpl, } from '../../../../src/domain/services/controllers/QueryReads.ts'; import type { QueryReadHost } from '../../../../src/domain/services/controllers/ReadGraphHost.ts'; import { @@ -49,6 +50,7 @@ describe('QueryReads property projection routing', () => { props: { weight: 3 }, }, ]); + await expect(getPropertyCountImpl(host)).resolves.toBe(3); }); it('keeps malformed public property queries as misses', async () => { diff --git a/test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts b/test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts new file mode 100644 index 00000000..c63e63ae --- /dev/null +++ b/test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../../src/domain/crdt/Dot.ts'; +import { LWWRegister } from '../../../../../src/domain/crdt/LWW.ts'; +import { + EDGE_PROP_PREFIX, + encodeEdgeKey, + encodeEdgePropKey, + encodePropKey, +} from '../../../../../src/domain/services/KeyCodec.ts'; +import StateQueryReadModel from '../../../../../src/domain/services/query/StateQueryReadModel.ts'; +import WarpState from '../../../../../src/domain/services/state/WarpState.ts'; +import type { PropValue } from '../../../../../src/domain/types/PropValue.ts'; +import { EventId } from '../../../../../src/domain/utils/EventId.ts'; + +describe('StateQueryReadModel property projection routing', () => { + it('reads node props through projection records and skips malformed compatibility keys', async () => { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodePropKey('node:1', 'status'), register(4, 'ready')); + state.prop.set(encodePropKey('node:1', 'secret'), register(5, 'hidden')); + state.prop.set(encodePropKey('node:1', '_content'), register(6, 'node-oid')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(7, 3)); + state.prop.set('node:1\0bad\0extra', register(8, 'ignored')); + state.prop.set(`${EDGE_PROP_PREFIX}node:1\0node:2\0rel\0bad\0extra`, register(9, 'ignored')); + + const model = new StateQueryReadModel({ + state, + stateHash: 'state:a', + visibility: { + match: 'node:*', + redact: ['secret'], + }, + }); + + await expect(model.nodeProps('node:1')).resolves.toEqual({ + _content: 'node-oid', + status: 'ready', + }); + await expect(model.nodeProps('missing')).resolves.toBeNull(); + }); +}); + +function addLiveNode(state: WarpState, nodeId: string, counter: number): void { + state.nodeAlive.add(nodeId, Dot.create('writer', counter)); +} + +function addLiveEdge( + state: WarpState, + from: string, + to: string, + label: string, + counter: number, +): void { + const edgeKey = encodeEdgeKey(from, to, label); + state.edgeAlive.add(edgeKey, Dot.create('writer', counter)); + state.edgeBirthEvent.set(edgeKey, event(counter)); +} + +function register(opIndex: number, value: PropValue): LWWRegister { + return LWWRegister.set(event(opIndex), value); +} + +function event(opIndex: number): EventId { + return new EventId(1, 'writer', 'abcd', opIndex); +} From 2eef3ba59a70c7707d6d918ca7a6cc90e645604c Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 15:50:08 -0700 Subject: [PATCH 06/11] Fix: Preserve state reader consumer type surface --- test/type-check/consumer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/type-check/consumer.ts b/test/type-check/consumer.ts index cf410ed0..2e8326d2 100644 --- a/test/type-check/consumer.ts +++ b/test/type-check/consumer.ts @@ -146,7 +146,7 @@ declare const logger: LoggerPort; declare const crypto: CryptoPort; declare const seekCache: SeekCachePort; declare const httpPort: HttpServerPort; -declare const liveState: Parameters[0]; +declare const liveState: Parameters[0]; declare const btrCodecOptions: Parameters[2]; declare const btrVerifyOptions: Parameters[2]; @@ -445,10 +445,12 @@ void decodedEdgePropKey; void edgePropKeyCheck; const reader = createStateReader(liveState); +const snapshotReader = createStateReader(materialized); const readerProps: PublicPropBag | null = reader.getNodeProps('node-a'); const readerEdges: PublicVisibleEdge[] = reader.getEdges(); const comparison = compareVisibleState(liveState, liveState, { targetId: 'node-a' }); +void snapshotReader; void readerProps; void readerEdges; void comparison; From 7004b0d182eab6dc34f2f9ee8cddfa6653296811 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 16:07:28 -0700 Subject: [PATCH 07/11] Fix: Parallelize CI type firewall gates --- .github/workflows/ci.yml | 106 ++++++++++++++++++++++++++++++++++++--- CHANGELOG.md | 4 ++ 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2e6f309..fb262afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,10 @@ on: jobs: # ── IRONCLAD M9 static firewall ────────────────────────────────────────── - # This job is the primary fast gate. It runs the static quality checks - # that should fail quickly before heavier runtime matrix jobs start. - # It MUST pass before any PR can merge. Configure as a required status check - # in GitHub branch protection settings. Security audit stays advisory here so - # CI keeps a single authoritative gate instead of duplicating lint/type work - # in a second job. - type-firewall: + # The `type-firewall` job below is the required aggregate status check. Its + # child jobs run the static gates in parallel so PRs wait for the slowest + # static lane instead of the sum of every static lane. + type-firewall-types: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -32,16 +29,49 @@ jobs: run: npm run typecheck:policy - name: 'Gate 3: Consumer type surface test' run: npm run typecheck:consumer + + type-firewall-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 4: ESLint (typed rules + no-explicit-any)' run: npm run lint - name: 'Gate 4b: Lint ratchet (zero-error invariant)' run: npm run lint:ratchet - name: 'Gate 4c: Anti-sludge shell checks (junk-drawer filenames)' run: npm run lint:sludge + + type-firewall-semgrep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 4d: Install semgrep' run: python -m pip install --upgrade pip semgrep - name: 'Gate 4e: Semgrep anti-sludge with rule-scoped quarantines' run: npm run lint:semgrep + + type-firewall-quarantine: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 4f: Contamination map matches checked-in manifests (no stale quarantines)' run: | npm run lint:contamination @@ -55,16 +85,76 @@ jobs: env: GIT_WARP_QUARANTINE_BASE: origin/main run: npm run lint:quarantine-graduate + + type-firewall-surface: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 5: Declaration surface validator (manifest vs index.d.ts vs index.js)' run: npm run typecheck:surface + + type-firewall-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 6: Markdown lint (fenced code blocks require language)' run: npm run lint:md - name: 'Gate 7: Markdown JS/TS code-sample syntax check' run: npm run lint:md:code + + type-firewall-audit-advisory: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - run: npm ci - name: 'Gate 8: Security audit (runtime deps, advisory)' - continue-on-error: true run: npm audit --omit=dev --audit-level=high + type-firewall: + runs-on: ubuntu-latest + needs: + - type-firewall-types + - type-firewall-lint + - type-firewall-semgrep + - type-firewall-quarantine + - type-firewall-surface + - type-firewall-docs + if: ${{ always() }} + steps: + - name: 'IRONCLAD M9 aggregate gate' + run: | + echo "types: ${{ needs['type-firewall-types'].result }}" + echo "lint: ${{ needs['type-firewall-lint'].result }}" + echo "semgrep: ${{ needs['type-firewall-semgrep'].result }}" + echo "quarantine: ${{ needs['type-firewall-quarantine'].result }}" + echo "surface: ${{ needs['type-firewall-surface'].result }}" + echo "docs: ${{ needs['type-firewall-docs'].result }}" + + test "${{ needs['type-firewall-types'].result }}" = "success" + test "${{ needs['type-firewall-lint'].result }}" = "success" + test "${{ needs['type-firewall-semgrep'].result }}" = "success" + test "${{ needs['type-firewall-quarantine'].result }}" = "success" + test "${{ needs['type-firewall-surface'].result }}" = "success" + test "${{ needs['type-firewall-docs'].result }}" = "success" + typecheck-test-advisory: runs-on: ubuntu-latest continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a13f51a..dcabe057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- CI now runs the heavy `type-firewall` static gates as parallel child jobs + behind a small required aggregate status, so TypeScript, ESLint, Semgrep, + quarantine, declaration-surface, and Markdown checks no longer serialize + behind one long-running job. - V18 property projection closeout now routes `StateQueryReadModel.nodeProps`, translation-cost property-key accounting, and public property counts through property projection records so malformed compatibility keys cannot leak into From 731193e9fe2229686a84653ab4280a8f6f8d907f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 16:36:20 -0700 Subject: [PATCH 08/11] Fix: Resolve property projection review findings --- CHANGELOG.md | 4 ++ src/domain/graph/publicGraphSubstrate.ts | 8 ++-- .../services/GraphOpAlgebraProjection.ts | 2 +- src/domain/services/PatchBuilder.ts | 43 ++----------------- src/domain/types/PropValue.ts | 14 +++--- 5 files changed, 19 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcabe057..b2b3b6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 property projection review follow-up now removes newly introduced + helper-level `unknown` suppressions from `PatchBuilder` property-value + validation and refreshes graph-op projection docs and public substrate export + ordering. - CI now runs the heavy `type-firewall` static gates as parallel child jobs behind a small required aggregate status, so TypeScript, ESLint, Semgrep, quarantine, declaration-surface, and Markdown checks no longer serialize diff --git a/src/domain/graph/publicGraphSubstrate.ts b/src/domain/graph/publicGraphSubstrate.ts index f43d6cca..fd7bf0de 100644 --- a/src/domain/graph/publicGraphSubstrate.ts +++ b/src/domain/graph/publicGraphSubstrate.ts @@ -7,10 +7,10 @@ export { default as ContentAttachmentPayload } from './ContentAttachmentPayload. export { default as ContentAttachmentRecord } from './ContentAttachmentRecord.ts'; export { default as ContentAttachmentSize } from './ContentAttachmentSize.ts'; export { default as ContentAttachmentWriteIntent } from './ContentAttachmentWriteIntent.ts'; -export { default as EdgePropertyWriteIntent } from './EdgePropertyWriteIntent.ts'; export { default as EdgeId } from './EdgeId.ts'; export { default as EdgeRecord } from './EdgeRecord.ts'; export { default as EdgeTypeId } from './EdgeTypeId.ts'; +export { default as EdgePropertyWriteIntent } from './EdgePropertyWriteIntent.ts'; export { default as GraphAttachmentSetOp } from './GraphAttachmentSetOp.ts'; export { default as GraphContentAttachmentSetOp } from './GraphContentAttachmentSetOp.ts'; export { default as GraphEdgeRecordSetOp } from './GraphEdgeRecordSetOp.ts'; @@ -23,9 +23,9 @@ export { default as LegacyNodePropertyKey } from './LegacyNodePropertyKey.ts'; export { default as LegacyPropertyProjection } from './LegacyPropertyProjection.ts'; export { default as LegacyPropertyValue } from './LegacyPropertyValue.ts'; export { default as NodeId } from './NodeId.ts'; -export { default as NodePropertyWriteIntent } from './NodePropertyWriteIntent.ts'; export { default as NodeRecord } from './NodeRecord.ts'; export { default as NodeTypeId } from './NodeTypeId.ts'; +export { default as NodePropertyWriteIntent } from './NodePropertyWriteIntent.ts'; export { default as VisibleEdgePropertyRecord } from './VisibleEdgePropertyRecord.ts'; export { default as VisibleNodePropertyRecord } from './VisibleNodePropertyRecord.ts'; @@ -67,12 +67,12 @@ export type { export type { ContentAttachmentPayloadFields } from './ContentAttachmentPayload.ts'; export type { ContentAttachmentRecordFields } from './ContentAttachmentRecord.ts'; export type { ContentAttachmentEdgeWriteTarget } from './ContentAttachmentWriteIntent.ts'; +export type { EdgeRecordFields, LegacyEdgeFields } from './EdgeRecord.ts'; export type { EdgePropertyWriteIntentFields, EdgePropertyWriteTarget, LegacyEdgePropertyWriteFields, } from './EdgePropertyWriteIntent.ts'; -export type { EdgeRecordFields, LegacyEdgeFields } from './EdgeRecord.ts'; export type { GraphAttachmentSetOpFields } from './GraphAttachmentSetOp.ts'; export type { GraphContentAttachmentSetOpFields } from './GraphContentAttachmentSetOp.ts'; export type { GraphEdgeRecordSetOpFields } from './GraphEdgeRecordSetOp.ts'; @@ -83,7 +83,7 @@ export type { GraphOpAlgebraFields } from './GraphOpAlgebra.ts'; export type { GraphOperation } from './GraphOperation.ts'; export type { LegacyPropertyKeyClassification } from './LegacyPropertyKeyClassification.ts'; export type { LegacyPropertyProjectionFields } from './LegacyPropertyProjection.ts'; -export type { NodePropertyWriteIntentFields } from './NodePropertyWriteIntent.ts'; export type { NodeRecordFields } from './NodeRecord.ts'; +export type { NodePropertyWriteIntentFields } from './NodePropertyWriteIntent.ts'; export type { VisibleEdgePropertyRecordFields } from './VisibleEdgePropertyRecord.ts'; export type { VisibleNodePropertyRecordFields } from './VisibleNodePropertyRecord.ts'; diff --git a/src/domain/services/GraphOpAlgebraProjection.ts b/src/domain/services/GraphOpAlgebraProjection.ts index 72ebed98..5eef492e 100644 --- a/src/domain/services/GraphOpAlgebraProjection.ts +++ b/src/domain/services/GraphOpAlgebraProjection.ts @@ -13,7 +13,7 @@ import type { GraphOperation } from '../graph/GraphOperation.ts'; /** Projects materialized graph state into the explicit graph-operation algebra. */ export default class GraphOpAlgebraProjection { - /** Returns graph operations ordered as nodes, edges, then attachments. */ + /** Returns graph operations ordered as nodes, edges, content, node props, then edge props. */ static fromState(state: WarpState): GraphOpAlgebra { const checkedState = requireWarpState(state); const operations: GraphOperation[] = []; diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index 8aee8ab3..29a9e242 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -44,7 +44,7 @@ import type LoggerPort from '../../ports/LoggerPort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; -import type { PropValue } from '../types/PropValue.ts'; +import { isPropValue, type PropValue } from '../types/PropValue.ts'; type ContentInput = AsyncIterable | ReadableStream | Uint8Array | string; type ContentMetadataInput = { mime?: string | null; size?: number | null }; @@ -461,52 +461,15 @@ export class PatchBuilder { } /** Validates public patch property values before intent construction. */ -function requirePatchPropertyValue(value: unknown): PropValue { // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary - if (isScalarPatchPropertyValue(value)) { +function requirePatchPropertyValue(value: T): PropValue { + if (isPropValue(value)) { return value; } - if (value instanceof Uint8Array) { - return value; - } - if (Array.isArray(value)) { - return value.map((entry) => requirePatchPropertyValue(entry)); - } - if (isPlainPatchPropertyObject(value)) { - const record: { [key: string]: PropValue } = {}; - for (const [key, entry] of Object.entries(value)) { - record[key] = requirePatchPropertyValue(entry); - } - return record; - } throw new PatchError('Property value must be property-compatible data', { code: 'E_PATCH_INVALID_PROPERTY_VALUE', }); } -/** Returns true for scalar property values. */ -function isScalarPatchPropertyValue( - value: unknown, // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary -): value is string | number | boolean | null { - return value === null - || typeof value === 'string' - || typeof value === 'number' - || typeof value === 'boolean'; -} - -/** Returns true for plain recursive property objects. */ -function isPlainPatchPropertyObject( - value: unknown, // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary -): value is { readonly [key: string]: unknown } { // nosemgrep: ts-no-unknown-outside-adapters -- public PatchBuilder boundary - if (value === null || typeof value !== 'object') { - return false; - } - if (Array.isArray(value) || value instanceof Uint8Array) { - return false; - } - return Object.getPrototypeOf(value) === Object.prototype - || Object.getPrototypeOf(value) === null; -} - async function storeContentAttachmentPayload( blobStorage: BlobStoragePort, content: ContentInput, diff --git a/src/domain/types/PropValue.ts b/src/domain/types/PropValue.ts index 9dbb0ab1..3abf458a 100644 --- a/src/domain/types/PropValue.ts +++ b/src/domain/types/PropValue.ts @@ -1,5 +1,3 @@ -import type CodecValue from './codec/CodecValue.ts'; - /** * PropValue — the set of values that can be stored in a CRDT property register. * @@ -16,7 +14,9 @@ export type PropValue = | PropValue[] | { [key: string]: PropValue }; -function isScalarPropValue(value: CodecValue): value is string | number | boolean | null | Uint8Array { +function isScalarPropValue( + value: T, +): value is T & (string | number | boolean | null | Uint8Array) { return ( value === null || typeof value === 'string' @@ -26,18 +26,18 @@ function isScalarPropValue(value: CodecValue): value is string | number | boolea ); } -function isPropValueArray(value: CodecValue): value is PropValue[] { +function isPropValueArray(value: T): value is T & PropValue[] { return Array.isArray(value) && value.every((entry) => isPropValue(entry)); } -function isPropValueObjectCandidate(value: CodecValue): value is { readonly [key: string]: CodecValue } { +function isPropValueObjectCandidate(value: T): value is T & object { if (value === null || typeof value !== 'object') { return false; } return isNonArrayPlainObject(value); } -function isPropValueObject(value: CodecValue): value is { [key: string]: PropValue } { +function isPropValueObject(value: T): value is T & { [key: string]: PropValue } { return isPropValueObjectCandidate(value) && Object.values(value).every((entry) => isPropValue(entry)); } @@ -50,7 +50,7 @@ function isNonArrayPlainObject(value: object): boolean { || Object.getPrototypeOf(value) === null; } -export function isPropValue(value: CodecValue): value is PropValue { +export function isPropValue(value: T): value is T & PropValue { return isScalarPropValue(value) || isPropValueArray(value) || isPropValueObject(value); From dfb1831af1932497167c2d76f7fcedcd4a321323 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 22:18:07 -0700 Subject: [PATCH 09/11] Fix: Resolve PR feedback threads --- .github/workflows/ci.yml | 71 +++++++++++++------ CHANGELOG.md | 4 ++ src/domain/services/state/StateReader.ts | 8 ++- .../services/state/StateReaderContext.ts | 8 ++- src/domain/types/PropValue.ts | 58 +++++++++++++-- .../PatchBuilderPropertyIntent.test.ts | 49 +++++++++++++ .../StateReaderPropertyProjection.test.ts | 34 +++++++++ 7 files changed, 197 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb262afa..b1a972fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: # ── IRONCLAD M9 static firewall ────────────────────────────────────────── # The `type-firewall` job below is the required aggregate status check. Its @@ -15,9 +18,11 @@ jobs: type-firewall-types: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -33,9 +38,11 @@ jobs: type-firewall-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -50,9 +57,11 @@ jobs: type-firewall-semgrep: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -65,9 +74,11 @@ jobs: type-firewall-quarantine: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -89,9 +100,11 @@ jobs: type-firewall-surface: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -102,9 +115,11 @@ jobs: type-firewall-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -118,9 +133,11 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -159,9 +176,11 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' @@ -175,9 +194,11 @@ jobs: matrix: node: [22] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '${{ matrix.node }}' cache: 'npm' @@ -198,23 +219,29 @@ jobs: test-bun: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Run Bun integration tests run: docker compose -f docker/docker-compose.test.yml run --rm test-bun test-deno: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Run Deno integration tests run: docker compose -f docker/docker-compose.test.yml run --rm test-deno coverage-threshold: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22' cache: 'npm' diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b3b6e7..f10b9664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 helper-level `unknown` suppressions from `PatchBuilder` property-value validation and refreshes graph-op projection docs and public substrate export ordering. +- V18 PR feedback follow-up now hardens CI checkout permissions and credential + handling, rejects cyclic and prototype-polluting property values before write + intent lowering, covers snapshot-backed state-reader parity, and avoids a + duplicate node-property projection pass during reader context construction. - CI now runs the heavy `type-firewall` static gates as parallel child jobs behind a small required aggregate status, so TypeScript, ESLint, Semgrep, quarantine, declaration-surface, and Markdown checks no longer serialize diff --git a/src/domain/services/state/StateReader.ts b/src/domain/services/state/StateReader.ts index bfa425c0..05855a38 100644 --- a/src/domain/services/state/StateReader.ts +++ b/src/domain/services/state/StateReader.ts @@ -193,18 +193,20 @@ function buildReaderApi(context: StateReaderContext): VisibleStateReader { function buildReaderContext(state: StateReaderSource): StateReaderContext { const projectionState = createStateReaderProjectionState(state); const baseProjection = projectState(projectionState); + const nodePropertyRecords = createNodePropertyRecords(projectionState); + const edgePropertyRecords = createEdgePropertyRecords(projectionState); const projection = { nodes: baseProjection.nodes, edges: baseProjection.edges, - props: createProjectionProps(projectionState), + props: createProjectionProps(nodePropertyRecords), }; const visibleNodeIds = new Set(projection.nodes); const nodePropsById = createNodePropIndex(projection.nodes); const edgePropsByKey = createEdgePropIndex(projection.edges); const { outgoingByNode, incomingByNode } = createNeighborIndex(projection.nodes, projection.edges); - populateVisibleNodeProps(createNodePropertyRecords(projectionState), nodePropsById); - populateVisibleEdgeProps(createEdgePropertyRecords(projectionState), edgePropsByKey); + populateVisibleNodeProps(nodePropertyRecords, nodePropsById); + populateVisibleEdgeProps(edgePropertyRecords, edgePropsByKey); return { projection, diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index 54bd14e8..9045a7e7 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -178,9 +178,11 @@ export function createNeighborIndex( return { outgoingByNode, incomingByNode }; } -/** Builds projection-backed public node property rows. */ -export function createProjectionProps(state: WarpState): VisibleProjectionProp[] { - return NodePropertyProjection.fromState(state).map((record) => ({ +/** Builds projection-backed public node property rows from precomputed records. */ +export function createProjectionProps( + records: readonly VisibleNodePropertyRecord[], +): VisibleProjectionProp[] { + return records.map((record) => ({ node: record.owner.id.toString(), key: record.key.toString(), value: record.value.toPropValue(), diff --git a/src/domain/types/PropValue.ts b/src/domain/types/PropValue.ts index 3abf458a..e2081c8d 100644 --- a/src/domain/types/PropValue.ts +++ b/src/domain/types/PropValue.ts @@ -14,6 +14,8 @@ export type PropValue = | PropValue[] | { [key: string]: PropValue }; +const FORBIDDEN_PROPERTY_VALUE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + function isScalarPropValue( value: T, ): value is T & (string | number | boolean | null | Uint8Array) { @@ -26,8 +28,19 @@ function isScalarPropValue( ); } -function isPropValueArray(value: T): value is T & PropValue[] { - return Array.isArray(value) && value.every((entry) => isPropValue(entry)); +function isPropValueArray(value: T, seen: WeakSet): value is T & PropValue[] { + if (!Array.isArray(value)) { + return false; + } + if (seen.has(value)) { + return false; + } + seen.add(value); + try { + return value.every((entry) => isPropValueWithSeen(entry, seen)); + } finally { + seen.delete(value); + } } function isPropValueObjectCandidate(value: T): value is T & object { @@ -37,9 +50,36 @@ function isPropValueObjectCandidate(value: T): value is T & object { return isNonArrayPlainObject(value); } -function isPropValueObject(value: T): value is T & { [key: string]: PropValue } { - return isPropValueObjectCandidate(value) - && Object.values(value).every((entry) => isPropValue(entry)); +function isForbiddenPropertyValueKey(key: string): boolean { + return FORBIDDEN_PROPERTY_VALUE_KEYS.has(key); +} + +function canTraversePropValueObject(value: T, seen: WeakSet): value is T & object { + return isPropValueObjectCandidate(value) && !seen.has(value); +} + +function isPropValueObject( + value: T, + seen: WeakSet, +): value is T & { [key: string]: PropValue } { + if (!canTraversePropValueObject(value, seen)) { + return false; + } + seen.add(value); + try { + return propValueObjectEntriesAreValid(value, seen); + } finally { + seen.delete(value); + } +} + +function propValueObjectEntriesAreValid(value: object, seen: WeakSet): boolean { + for (const [key, entry] of Object.entries(value)) { + if (isForbiddenPropertyValueKey(key) || !isPropValueWithSeen(entry, seen)) { + return false; + } + } + return true; } function isNonArrayPlainObject(value: object): boolean { @@ -51,7 +91,11 @@ function isNonArrayPlainObject(value: object): boolean { } export function isPropValue(value: T): value is T & PropValue { + return isPropValueWithSeen(value, new WeakSet()); +} + +function isPropValueWithSeen(value: T, seen: WeakSet): value is T & PropValue { return isScalarPropValue(value) - || isPropValueArray(value) - || isPropValueObject(value); + || isPropValueArray(value, seen) + || isPropValueObject(value, seen); } diff --git a/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts b/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts index da6f4918..c4fe0959 100644 --- a/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts +++ b/test/unit/domain/services/PatchBuilderPropertyIntent.test.ts @@ -60,8 +60,57 @@ describe('PatchBuilder property intent lowering', () => { }).toThrow(/NodeId/); expect(builder.build().ops).toEqual([]); }); + + it('accepts bytes, arrays, and recursive property-compatible objects', () => { + const builder = createBuilder(null); + const bytes = new Uint8Array([1, 2, 3]); + + builder.setProperty('node:1', 'payload', { + bytes, + nested: [1, 'ok', { done: true }], + }); + + const patch = builder.build(); + expect(patch.schema).toBe(2); + const op = requirePropSet(patch.ops[0]); + expect(op.node).toBe('node:1'); + expect(op.key).toBe('payload'); + expect(op.value).toEqual({ + bytes, + nested: [1, 'ok', { done: true }], + }); + }); + + it('rejects cyclic property values before appending operations', () => { + const builder = createBuilder(null); + const cyclic: CyclicPropertyValue = {}; + cyclic.self = cyclic; + + expect(() => { + builder.setProperty('node:1', 'payload', cyclic); + }).toThrow(PatchError); + expect(builder.build().ops).toEqual([]); + }); + + it('rejects prototype-polluting object keys before appending operations', () => { + const builder = createBuilder(null); + const payload = { safe: 'ok' }; + Object.defineProperty(payload, '__proto__', { + value: 'polluted', + enumerable: true, + }); + + expect(() => { + builder.setProperty('node:1', 'payload', payload); + }).toThrow(PatchError); + expect(builder.build().ops).toEqual([]); + }); }); +type CyclicPropertyValue = { + self?: CyclicPropertyValue; +}; + function createBuilder(state: WarpState | null): PatchBuilder { return new PatchBuilder({ persistence: unusedPersistence(), diff --git a/test/unit/domain/services/StateReaderPropertyProjection.test.ts b/test/unit/domain/services/StateReaderPropertyProjection.test.ts index 0476066d..afa65aa8 100644 --- a/test/unit/domain/services/StateReaderPropertyProjection.test.ts +++ b/test/unit/domain/services/StateReaderPropertyProjection.test.ts @@ -7,6 +7,7 @@ import { getNodePropsImpl, } from '../../../../src/domain/services/controllers/QueryReads.ts'; import type { QueryReadHost } from '../../../../src/domain/services/controllers/ReadGraphHost.ts'; +import { createSnapshotWarpState } from '../../../../src/domain/services/ImmutableSnapshot.ts'; import { CONTENT_MIME_PROPERTY_KEY, CONTENT_PROPERTY_KEY, @@ -78,8 +79,41 @@ describe('StateReader property projection routing', () => { size: null, }); }); + + it('reads projection views from immutable snapshot sources with live-state parity', () => { + const state = stateWithProjectionFacts(); + const liveReader = createStateReader(state); + const snapshotReader = createStateReader(createSnapshotWarpState(state)); + + expect(snapshotReader.getNodeProps('node:1')).toEqual(liveReader.getNodeProps('node:1')); + expect(snapshotReader.getEdgeProps('node:1', 'node:2', 'rel')).toEqual( + liveReader.getEdgeProps('node:1', 'node:2', 'rel'), + ); + expect(snapshotReader.getEdges()).toEqual(liveReader.getEdges()); + expect(snapshotReader.project().props).toEqual(liveReader.project().props); + expect(snapshotReader.getNodeContentMeta('node:1')).toEqual( + liveReader.getNodeContentMeta('node:1'), + ); + expect(snapshotReader.getEdgeContentMeta('node:1', 'node:2', 'rel')).toEqual( + liveReader.getEdgeContentMeta('node:1', 'node:2', 'rel'), + ); + }); }); +function stateWithProjectionFacts(): WarpState { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + addLiveNode(state, 'node:2', 2); + addLiveEdge(state, 'node:1', 'node:2', 'rel', 3); + state.prop.set(encodePropKey('node:1', 'status'), register(4, 'ready')); + state.prop.set(encodePropKey('node:1', CONTENT_PROPERTY_KEY), register(5, 'node-oid')); + state.prop.set(encodePropKey('node:1', CONTENT_SIZE_PROPERTY_KEY), register(5, 512)); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', 'weight'), register(6, 3)); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', CONTENT_PROPERTY_KEY), register(7, 'edge-oid')); + state.prop.set(encodeEdgePropKey('node:1', 'node:2', 'rel', CONTENT_MIME_PROPERTY_KEY), register(7, 'text/plain')); + return state; +} + function hostForState(state: WarpState): QueryReadHost { return { _cachedState: state, From c5774190c360ab77529e78ffd83076b48885d1ab Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 23:25:03 -0700 Subject: [PATCH 10/11] Fix: Harden snapshot property hydration --- CHANGELOG.md | 3 + .../services/state/StateReaderContext.ts | 91 ++++++++++++++++++- .../StateReaderPropertyProjection.test.ts | 46 +++++++++- 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f10b9664..2f1d9134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 snapshot-backed state-reader hydration now rejects cyclic property + values and prototype-polluting object keys at the snapshot boundary before + converting immutable property values back into projection-local registers. - V18 property projection review follow-up now removes newly introduced helper-level `unknown` suppressions from `PatchBuilder` property-value validation and refreshes graph-op projection docs and public substrate export diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index 9045a7e7..d3bf69fa 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -29,12 +29,19 @@ export type VisiblePropertyBag = Readonly<{ [key: string]: SnapshotPropValue }>; type MutableVisiblePropertyBag = { [key: string]: SnapshotPropValue }; export type VisibleEdgeView = { from: string; to: string; label: string; props: VisiblePropertyBag }; export type StateReaderSource = WarpState | SnapshotWarpState; +type SnapshotPropValueObject = { readonly [key: string]: SnapshotPropValue }; type VisibleProjectionProp = { node: string; key: string; value: PropValue; }; +const FORBIDDEN_SNAPSHOT_PROPERTY_VALUE_KEYS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + export type StateReaderContext = { projection: { nodes: string[]; @@ -123,20 +130,94 @@ function propMapFromSnapshot( /** Converts immutable snapshot values back into projection-local values. */ function propValueFromSnapshot(value: SnapshotPropValue): PropValue { + return propValueFromSnapshotWithSeen(value, new WeakSet()); +} + +/** Converts immutable snapshot values while detecting invalid recursion. */ +function propValueFromSnapshotWithSeen( + value: SnapshotPropValue, + seen: WeakSet, +): PropValue { if (value instanceof ImmutableBytes) { return value.toUint8Array(); } - if (Array.isArray(value)) { - return value.map((entry) => propValueFromSnapshot(entry)); + if (isSnapshotPropValueArray(value)) { + return propValueArrayFromSnapshot(value, seen); + } + if (isSnapshotPropValueObject(value)) { + return propValueObjectFromSnapshot(value, seen); + } + return value; +} + +/** Converts a snapshot array branch while rejecting cyclic aliases. */ +function propValueArrayFromSnapshot( + value: readonly SnapshotPropValue[], + seen: WeakSet, +): PropValue[] { + requireUnseenSnapshotPropertyValue(value, seen); + seen.add(value); + try { + return value.map((entry) => propValueFromSnapshotWithSeen(entry, seen)); + } finally { + seen.delete(value); } - if (value !== null && typeof value === 'object') { +} + +/** Converts a snapshot object branch while rejecting prototype keys. */ +function propValueObjectFromSnapshot( + value: SnapshotPropValueObject, + seen: WeakSet, +): { [key: string]: PropValue } { + requireUnseenSnapshotPropertyValue(value, seen); + seen.add(value); + try { const props: { [key: string]: PropValue } = {}; for (const [key, entry] of Object.entries(value)) { - props[key] = propValueFromSnapshot(entry); + requireSnapshotPropertyValueKey(key); + props[key] = propValueFromSnapshotWithSeen(entry, seen); } return props; + } finally { + seen.delete(value); } - return value; +} + +/** Returns true for snapshot array branches. */ +function isSnapshotPropValueArray( + value: SnapshotPropValue, +): value is readonly SnapshotPropValue[] { + return Array.isArray(value); +} + +/** Returns true for snapshot property dictionary branches. */ +function isSnapshotPropValueObject(value: SnapshotPropValue): value is SnapshotPropValueObject { + return value !== null + && typeof value === 'object' + && !(value instanceof ImmutableBytes) + && !Array.isArray(value); +} + +/** Rejects cyclic snapshot value aliases before recursive hydration. */ +function requireUnseenSnapshotPropertyValue(value: object, seen: WeakSet): void { + if (seen.has(value)) { + throw invalidSnapshotPropertyValueError(); + } +} + +/** Rejects keys that can mutate object prototypes during hydration. */ +function requireSnapshotPropertyValueKey(key: string): void { + if (FORBIDDEN_SNAPSHOT_PROPERTY_VALUE_KEYS.has(key)) { + throw invalidSnapshotPropertyValueError(); + } +} + +/** Builds the snapshot property hydration validation error. */ +function invalidSnapshotPropertyValueError(): WarpError { + return new WarpError( + 'Snapshot property value must be property-compatible data', + 'E_STATE_READER_INVALID_SNAPSHOT_PROPERTY_VALUE', + ); } // ── Index builders ─────────────────────────────────────────────────────────── diff --git a/test/unit/domain/services/StateReaderPropertyProjection.test.ts b/test/unit/domain/services/StateReaderPropertyProjection.test.ts index afa65aa8..2aaa1ec8 100644 --- a/test/unit/domain/services/StateReaderPropertyProjection.test.ts +++ b/test/unit/domain/services/StateReaderPropertyProjection.test.ts @@ -7,7 +7,11 @@ import { getNodePropsImpl, } from '../../../../src/domain/services/controllers/QueryReads.ts'; import type { QueryReadHost } from '../../../../src/domain/services/controllers/ReadGraphHost.ts'; -import { createSnapshotWarpState } from '../../../../src/domain/services/ImmutableSnapshot.ts'; +import { + createSnapshotORSet, + createSnapshotVersionVector, + createSnapshotWarpState, +} from '../../../../src/domain/services/ImmutableSnapshot.ts'; import { CONTENT_MIME_PROPERTY_KEY, CONTENT_PROPERTY_KEY, @@ -17,6 +21,8 @@ import { encodeEdgePropKey, encodePropKey, } from '../../../../src/domain/services/KeyCodec.ts'; +import SnapshotWarpState from '../../../../src/domain/services/snapshot/SnapshotWarpState.ts'; +import type { SnapshotPropValue } from '../../../../src/domain/services/snapshot/SnapshotPropValue.ts'; import { createStateReader } from '../../../../src/domain/services/state/StateReader.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; import type { PropValue } from '../../../../src/domain/types/PropValue.ts'; @@ -98,6 +104,30 @@ describe('StateReader property projection routing', () => { liveReader.getEdgeContentMeta('node:1', 'node:2', 'rel'), ); }); + + it('rejects cyclic snapshot property values during reader hydration', () => { + const cyclic = {}; + Object.defineProperty(cyclic, 'self', { + value: cyclic, + enumerable: true, + }); + + expect(() => createStateReader(snapshotWithPropertyValue(cyclic))).toThrow( + /Snapshot property value/, + ); + }); + + it('rejects prototype-polluting snapshot property keys during reader hydration', () => { + const payload = { safe: 'ok' }; + Object.defineProperty(payload, '__proto__', { + value: { polluted: true }, + enumerable: true, + }); + + expect(() => createStateReader(snapshotWithPropertyValue(payload))).toThrow( + /Snapshot property value/, + ); + }); }); function stateWithProjectionFacts(): WarpState { @@ -125,6 +155,20 @@ function hostForState(state: WarpState): QueryReadHost { }; } +function snapshotWithPropertyValue(value: SnapshotPropValue): SnapshotWarpState { + const state = WarpState.empty(); + addLiveNode(state, 'node:1', 1); + return new SnapshotWarpState({ + nodeAlive: createSnapshotORSet(state.nodeAlive), + edgeAlive: createSnapshotORSet(state.edgeAlive), + prop: new Map([ + [encodePropKey('node:1', 'payload'), LWWRegister.set(event(2), value)], + ]), + observedFrontier: createSnapshotVersionVector(state.observedFrontier), + edgeBirthEvent: new Map(state.edgeBirthEvent), + }); +} + function addLiveNode(state: WarpState, nodeId: string, counter: number): void { state.nodeAlive.add(nodeId, Dot.create('writer', counter)); } From c6be78a2d7d475761b7f0e4f7b11990467bc8bb1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 23 May 2026 23:36:01 -0700 Subject: [PATCH 11/11] Fix: Validate snapshot property object shape --- CHANGELOG.md | 5 ++- .../services/state/StateReaderContext.ts | 42 +++++++++++++++++-- .../StateReaderPropertyProjection.test.ts | 23 ++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1d9134..41cc5c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,8 +83,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - V18 snapshot-backed state-reader hydration now rejects cyclic property - values and prototype-polluting object keys at the snapshot boundary before - converting immutable property values back into projection-local registers. + values, prototype-polluting object keys, custom-prototype property bags, and + accessor-backed property objects at the snapshot boundary before converting + immutable property values back into projection-local registers. - V18 property projection review follow-up now removes newly introduced helper-level `unknown` suppressions from `PatchBuilder` property-value validation and refreshes graph-op projection docs and public substrate export diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index d3bf69fa..1ac31585 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -144,7 +144,7 @@ function propValueFromSnapshotWithSeen( if (isSnapshotPropValueArray(value)) { return propValueArrayFromSnapshot(value, seen); } - if (isSnapshotPropValueObject(value)) { + if (isSnapshotPropValueObjectCandidate(value)) { return propValueObjectFromSnapshot(value, seen); } return value; @@ -169,6 +169,7 @@ function propValueObjectFromSnapshot( value: SnapshotPropValueObject, seen: WeakSet, ): { [key: string]: PropValue } { + requireSnapshotPropValueObject(value); requireUnseenSnapshotPropertyValue(value, seen); seen.add(value); try { @@ -190,14 +191,49 @@ function isSnapshotPropValueArray( return Array.isArray(value); } -/** Returns true for snapshot property dictionary branches. */ -function isSnapshotPropValueObject(value: SnapshotPropValue): value is SnapshotPropValueObject { +/** Returns true for possible snapshot property dictionary branches. */ +function isSnapshotPropValueObjectCandidate( + value: SnapshotPropValue, +): value is SnapshotPropValueObject { return value !== null && typeof value === 'object' && !(value instanceof ImmutableBytes) && !Array.isArray(value); } +/** Rejects non-plain or accessor-backed snapshot property dictionaries. */ +function requireSnapshotPropValueObject(value: SnapshotPropValueObject): void { + if (!isPlainSnapshotPropValueObject(value)) { + throw invalidSnapshotPropertyValueError(); + } + if (!snapshotPropertyObjectHasOnlyDataDescriptors(value)) { + throw invalidSnapshotPropertyValueError(); + } +} + +/** Returns true for plain snapshot property dictionaries. */ +function isPlainSnapshotPropValueObject(value: SnapshotPropValueObject): boolean { + const prototype = Reflect.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Returns true when snapshot properties cannot execute accessors. */ +function snapshotPropertyObjectHasOnlyDataDescriptors(value: SnapshotPropValueObject): boolean { + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) { + if (!isDataPropertyDescriptor(descriptor)) { + return false; + } + } + return true; +} + +/** Returns true for descriptors that expose data instead of accessors. */ +function isDataPropertyDescriptor(descriptor: PropertyDescriptor): boolean { + return Object.hasOwn(descriptor, 'value') + && descriptor.get === undefined + && descriptor.set === undefined; +} + /** Rejects cyclic snapshot value aliases before recursive hydration. */ function requireUnseenSnapshotPropertyValue(value: object, seen: WeakSet): void { if (seen.has(value)) { diff --git a/test/unit/domain/services/StateReaderPropertyProjection.test.ts b/test/unit/domain/services/StateReaderPropertyProjection.test.ts index 2aaa1ec8..93aed533 100644 --- a/test/unit/domain/services/StateReaderPropertyProjection.test.ts +++ b/test/unit/domain/services/StateReaderPropertyProjection.test.ts @@ -128,6 +128,29 @@ describe('StateReader property projection routing', () => { /Snapshot property value/, ); }); + + it('rejects custom-prototype snapshot property objects during reader hydration', () => { + const payload = { safe: 'ok' }; + Object.setPrototypeOf(payload, { inherited: 'not-a-property-bag' }); + + expect(() => createStateReader(snapshotWithPropertyValue(payload))).toThrow( + /Snapshot property value/, + ); + }); + + it('rejects accessor-backed snapshot property objects without invoking getters', () => { + const payload = {}; + Object.defineProperty(payload, 'trap', { + get() { + throw new RangeError('snapshot getter should not run'); + }, + enumerable: true, + }); + + expect(() => createStateReader(snapshotWithPropertyValue(payload))).toThrow( + /Snapshot property value/, + ); + }); }); function stateWithProjectionFacts(): WarpState {